diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md new file mode 100644 index 00000000..aa027cf1 --- /dev/null +++ b/.changeset/record-inventory.md @@ -0,0 +1,18 @@ +--- +"@provablehq/veil-core": minor +"@provablehq/veil-aleo-sdk": minor +"@provablehq/shield-swap-sdk": minor +"@provablehq/shield-swap-cli": minor +--- + +Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add shield-swap inventory commands with bounded maintenance policies and a CLI-only SQLite recovery journal exported from @provablehq/shield-swap-cli/storage. SDK packages retain their existing runtime requirements. + +Allow inventory assets to specify only a program, with optional record-name and amount-field overrides. Resolve amount before microcredits for custom records and use the deployed unsigned integer width. + +Prefer available batch join helpers for more than two records: up to 16 credits or dedicated stablecoin records, and 15 dynamic ARC records. Validate helper interfaces, capture them in plans, and match their confirmed outputs before dependent steps. Explicit dynamic routers remain supported; tokenJoin: false selects native pairwise joins. Reservations and recovery remain off unless recordManagement.enabled is true. + +Support private fees on managed local-account writes. Prefer an eligible supplied fee record, otherwise reserve a sufficient unreserved credits record alongside the execution inputs. Pass that record through local, delegated, and devnode proving, verify it before broadcast, and retain charged private fee records after rejected executions. + +Retry automatic local Shield Swap record selection on pre-proving reservation conflicts. Keep swap terms fixed, bound retries to one scan, and preserve explicit inputs and execution errors. Export RecordReservationConflictError for input acquisition failures. + +Record per-entry rebroadcast attempts and errors without releasing uncertain inputs or preventing later entries from being reconciled. Show recovery diagnostics in CLI status and waiting results. Reject unsupported wallet fee ceilings before scanning nonempty inventory plans while allowing no-op plans. diff --git a/.gitignore b/.gitignore index fe428fc0..60735faf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ packages/codegen/test/integration/generated/ # Bridge Solana deposit verification script state (private key + checkpoints) packages/bridge/scripts/.env.solana-deposit packages/bridge/scripts/.solana-deposit.state.json + +# Local inventory transaction journal +.veil/ diff --git a/README.md b/README.md index 507ed9ec..895e6e9b 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,23 @@ const tools = aleoAgentTools({ // Each tool has { schema, handler } — plug into LangChain, Vercel AI SDK, etc. ``` +## Record inventory management + +Core reads records from a scanner and joins or splits them to prepare an +ARC20, ARC22, native credits, or compatible custom balance for spending. An asset +needs only its program; optional `recordName` and `amountField` select a custom +balance. Joins of more than two records prefer available batch helper programs. +Shield Swap applies these operations to each DEX token's underlying program. +[`shield-swap inventory`](packages/shield-swap-cli/INVENTORY.md) runs background +maintenance and stores recovery data in SQLite. + +Record reservations and recovery are off by default. Follow the +[record management guide](site/docs/guides/record-inventory.md) to enable them, +configure storage, select private fee records, and recover interrupted work. +The [concurrent swaps walkthrough](site/docs/guides/shield-swap.md#concurrent-swaps) +shows how to prepare records, share both stores, and claim successful swaps +when another trade fails. + ## License MIT diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 8b6dedc9..33b275b6 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -34,6 +34,10 @@ export function createAleoWalletClient(adapter: AleoWalletAdapter) { account, // Wallet transport handles executions; http handles reads as fallback transport: fallback([walletTransport, http(API_URL, { network: 'mainnet' })]), + // Configure record planning without enabling reservations on wallet writes. + // Managed private fees need a pinned fee record, which the current wallet + // protocol cannot request. Local SDK signers support that selection. + recordManagement: { enabled: false, chainId: 'aleo:mainnet' }, }) } @@ -60,3 +64,23 @@ export async function getLoyaltyContract(walletClient?: ReturnType) { + return walletClient +} diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index 4c3e2930..2552738f 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -10,6 +10,10 @@ import { beforeAll, describe, it, expect } from 'vitest' import { createPublicClient, + createWalletClient, + custom, + memoryRecordInventoryStore, + TransportError, http, parseProgram, getContract, @@ -235,3 +239,65 @@ describe('E2E: veil against live Aleo mainnet', () => { console.log(' aleo_describe_program: found', descResult.functions.length, 'functions,', descResult.mappings.length, 'mappings') }) }, { timeout: 30_000 }) + +// Inventory planning consumes the account's provider and performs no writes. +// A live application supplies createRemoteScanner({ waitForSync: true }) instead. +// With management enabled, privateFee: true also reserves a separate credits +// record. An optional feeRecord is preferred only while it remains available; +// the client replaces reserved candidates before building the transaction. +// Managed swaps can reselect automatically before proving. Direct writes and +// explicit tokenRecord inputs instead surface RecordReservationConflictError; +// proving/submission failures must be reconciled before any new spend. +describe('Record inventory consumer example', () => { + it('inspects failed rebroadcasts while keeping uncertain records reserved', async () => { + const aleo = await loadNetwork('testnet') + const account = aleo.generateAccount() + const store = memoryRecordInventoryStore() + const scope = JSON.stringify(['aleo:testnet', account.address]) + // Model a saved proof and an unavailable node without sending a transaction. + await store.acquire({ scope, records: ['1group'], program: 'credits.aleo', function: 'split', + status: 'prepared', accountType: 'local', createdAt: Date.now(), transactionId: 'at1example', + transaction: { id: 'at1example', type: 'execute' } as any }) + const client = createWalletClient({ account, + transport: custom({ request: async ({ method }) => { + if (method === 'getConfirmedTransaction') throw new TransportError('Not found', { status: 404 }) + throw new TransportError('Node unavailable') + } }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + }) + const journal = await client.reconcileRecordInventory({ rebroadcast: true }) + const pending = journal.filter(entry => ['reserved', 'prepared', 'submitted'].includes(entry.status)) + const failures = pending.map(entry => ({ + transactionId: entry.transactionId, + attempts: entry.rebroadcastAttempts ?? 0, + error: entry.lastRebroadcastError, + })) + expect(failures).toEqual([{ transactionId: 'at1example', attempts: 1, error: 'Node unavailable' }]) + expect(pending[0]!.records).toEqual(['1group']) + expect(pending[0]!.status).toBe('prepared') + }) + it('plans four credits records while keeping the source record private', async () => { + const aleo = await loadNetwork('testnet') + const account = aleo.generateAccount() + const client = createWalletClient({ + account, + transport: http(API_URL, { network: 'testnet' }), + proving: aleo.createProvingConfig({ mode: 'delegated', networkUrl: API_URL, account }), + // Routing and planning remain available without enabling reservations. + recordManagement: { enabled: false, chainId: 'aleo:testnet' }, + recordProvider: { requestRecords: async () => [{ + programName: 'credits.aleo', recordName: 'credits', tag: '1field', spent: false, + recordPlaintext: `{ owner: ${account.address}.private, microcredits: 4030000u64.private, _nonce: 1group.public }`, + }] }, + }) + const plan = await client.planRecordInventory({ + asset: { program: 'credits.aleo' }, + target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, + }) + expect(client.recordManagement?.enabled).toBe(false) + expect(client.recordManagement?.store).toBeUndefined() + expect(plan.steps).toHaveLength(3) + expect(plan.deduction).toBe(30_000n) + expect(plan.inputs).toEqual([{ id: '1group', amount: 4_030_000n }]) + }) +}) diff --git a/examples/shield-swap/README.md b/examples/shield-swap/README.md new file mode 100644 index 00000000..866f3132 --- /dev/null +++ b/examples/shield-swap/README.md @@ -0,0 +1,116 @@ +# Shield Swap examples + +These examples call the Shield Swap SDK from TypeScript. Start with +[setup-client.ts](./setup-client.ts) for account setup and +[swap.ts](./swap.ts) for a single swap and claim. + +## Run concurrent swaps + +[concurrent-swaps.ts](./concurrent-swaps.ts) prepares six balanced USDCx +records, submits six USDCx → ETH swaps concurrently, and claims their outputs. +Each trade spends 0.1 USDCx by default. Preparing the records can join and split +**the account's entire available USDCx inventory**; it leaves reserved records +out of the plan. At least 0.6 USDCx must be available. + +Run from the repository root with Node 22.13 or newer and dependencies installed +(`pnpm install`). Set `VEIL_E2E_PRIVATE_KEY` in the local environment to the private key of an +existing funded testnet account with DEX access. The example neither +creates nor funds an account. For setup, follow the +[Shield Swap runbook](../../packages/shield-swap/skills/SKILL.md). + +```sh +# Reads inventory, lists maintenance operations, and quotes a trade. No submissions. +pnpm exec tsx examples/shield-swap/concurrent-swaps.ts + +# Prepares the records, submits six swaps, and claims each successful output. +pnpm exec tsx examples/shield-swap/concurrent-swaps.ts --execute +``` + +Execution uses delegated proving with FeeMaster paying network fees. It +sets `recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }` +on the wallet client. The client already includes record methods; +`shieldSwapActions()` adds the trading methods. +Record management remains disabled for clients that omit that configuration. + +To change the trade terms, import the example from another local script: + +```ts +import { concurrentSwaps } from './concurrent-swaps.js' + +// amountIn is a decimal string in input-token units. Preview is the default. +await concurrentSwaps({ from: 'USDCx', to: 'ETH', amountIn: '0.2', count: 6 }) + +// Run separately after inspecting the preview; this spends 1.2 USDCx in total. +await concurrentSwaps({ from: 'USDCx', to: 'ETH', amountIn: '0.2', count: 6, execute: true }) +``` + +The plan requires one sufficient record per trade. Each `client.swap({ quote })` +selects its own input and acquires a reservation atomically. If another swap +reserves that input first, the SDK selects another candidate before proving. +The example leaves `tokenRecord` unset so this selection path applies. +Quotes retain their minimum output, but do not reserve pool liquidity; concurrent +trades can still fail as pool prices change. + +`submitConcurrentSwaps` uses `Promise.allSettled` so one failed submission does +not discard successful handles. It then waits for every successful swap's +output and attempts its claim. A failed claim does not prevent other claims. +The helper returns `swaps`, `handles`, and `claims`. Each `claims[i]` belongs to +`handles[i]`, including failed claims that need recovery. Successful claims +report both the output and any unspent input refund. + +## Keep recovery data + +The default directory is `.veil/concurrent-swaps/testnet//`. +Pass `directory` to choose another location, and reuse it for that account: + +| File | Purpose | +| --- | --- | +| `inventory.sqlite` | Stores record reservations and prepared transactions for reconciliation. | +| `blinded.json` | Stores sensitive blinded identities and handles needed to claim swap outputs. | +| `run.lock` | Prevents overlapping runs of this example against the same directory. | +| `batch.pending` | Remains after an interrupted batch so a later execution cannot silently repeat it. | + +Keep these files private and outside version control. SQLite coordinates record +reservations across processes, but the identity store coordinates calls only +within one process. **Do not run another trader for the same account while this +example is running**, even with a different directory. Multiple clients within +one process must share the same inventory store and identity store objects. + +After an interruption: + +1. Stop the previous process. Remove a stale `run.lock` only after confirming + that process has exited. +2. Recreate the client with the same account, network, `inventory.sqlite`, and + `blinded.json` paths shown in the example. Call `client.reconcileRecordInventory()` + to inspect pending transactions. A missing confirmation or a timeout does not + prove failure; keep its reservation. The record guide describes + [exact-transaction rebroadcast](../../site/docs/guides/record-inventory.md). +3. Call `client.syncBlindedIdentities()` and `client.getUnclaimedSwaps()` to + locate finalized outputs. Claim entries carrying a handle with + `client.claimSwapOutput({ handle })`. Investigate unresolved submissions and + missing handles through the journal and swap history before any new trade. +4. Remove `batch.pending` only after every submitted transaction and claim has + been resolved. Preview the current inventory again before starting a new batch. + +The example never automatically rebuilds or repeats a swap after an uncertain +submission. All successful sibling swaps finish their claim attempts before an +error is reported, and both persistent stores survive that error. + +## Check the example + +```sh +# Offline checks of concurrent scheduling, claim failures, and command guards. +pnpm exec vitest run examples/shield-swap/concurrent-swaps.test.ts +pnpm exec tsc -p examples/shield-swap/tsconfig.json + +# Live, read-only preview with a funded account already in the environment. +VEIL_INTEGRATION=1 pnpm exec vitest run examples/shield-swap/examples.test.ts -t 'concurrent-swaps preview' + +# Explicitly opt into the funded six-swap batch and its inventory maintenance. +VEIL_INTEGRATION=1 VEIL_EXAMPLES_SPEND=1 VEIL_CONCURRENT_EXAMPLE=1 \ + pnpm exec vitest run examples/shield-swap/examples.test.ts -t 'prepares six records' +``` + +The offline tests replace network actions to check the example's orchestration. +The opt-in test exercises real scanning, planning, joins/splits when needed, +record reservations, delegated proving, submission, confirmation, and claims. diff --git a/examples/shield-swap/concurrent-swaps.test.ts b/examples/shield-swap/concurrent-swaps.test.ts new file mode 100644 index 00000000..c1dc2993 --- /dev/null +++ b/examples/shield-swap/concurrent-swaps.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import type { ShieldSwapActions, SwapQuote } from '@provablehq/shield-swap-sdk' +import { submitConcurrentSwaps } from './concurrent-swaps.js' + +const offer: SwapQuote = { + version: 1, network: 'testnet', program: 'shield_swap.aleo', + from: { id: '1field', symbol: 'USDCx', decimals: 6 }, + to: { id: '2field', symbol: 'ETH', decimals: 18 }, + amountIn: 100_000n, expectedOut: 100n, minOut: 99n, slippageBps: 100, + hops: [{ poolKey: '3field', tokenInId: '1field', tokenOutId: '2field' }], + quotedAt: 1, expiresAt: 60_001, protocolRevision: 1, +} +type Handle = Awaited> +// Network-bound actions are substituted here; these tests exercise the example's +// scheduling and recovery logic. Record contention is covered by the SDK suites. +const handle = (id: number) => ({ transactionId: `swap-${id}`, swapId: `${id}field`, program: 'shield_swap.aleo' }) as Handle + +it('starts every swap before awaiting results and claims successes despite a failed submission', async () => { + const releases: Array<(value: Handle) => void> = [] + const failed = new Error('submission outcome uncertain') + const swap = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve))) + .mockRejectedValueOnce(failed) + .mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve))) + const waitForSwapOutput = vi.fn().mockResolvedValue({}) + const claimSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => ({ + transactionId: `claim-${item.transactionId}`, amountOut: 100n, amountRemaining: 0n, + })) + + const pending = submitConcurrentSwaps({ swap, waitForSwapOutput, claimSwapOutput }, [offer, offer, offer]) + expect(swap).toHaveBeenCalledTimes(3) + expect(waitForSwapOutput).not.toHaveBeenCalled() + releases[0]!(handle(1)) + releases[1]!(handle(3)) + const results = await pending + + expect(results.swaps.map((result) => result.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']) + expect(results.swaps[1]).toMatchObject({ reason: failed }) + expect(results.handles).toEqual([handle(1), handle(3)]) + expect(results.claims.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(claimSwapOutput).toHaveBeenCalledTimes(2) + expect(swap.mock.calls).toEqual([[{ quote: offer }], [{ quote: offer }], [{ quote: offer }]]) +}) + +describe('claim recovery', () => { + it.each(['wait', 'claim'])('retains a handle after %s failure without resubmitting or abandoning other claims', async (phase) => { + let id = 0 + const swap = vi.fn().mockImplementation(async () => handle(++id)) + const failure = new Error('confirmation timed out') + const waitForSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => { + if (phase === 'wait' && item.transactionId === 'swap-1') throw failure + return {} + }) + const claimSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => { + if (phase === 'claim' && item.transactionId === 'swap-1') throw failure + return { transactionId: `claim-${item.transactionId}`, amountOut: 80n, amountRemaining: 20n } + }) + + const results = await submitConcurrentSwaps({ swap, waitForSwapOutput, claimSwapOutput }, [offer, offer]) + expect(results.handles).toEqual([handle(1), handle(2)]) + expect(results.claims[0]).toMatchObject({ status: 'rejected', reason: failure }) + expect(results.claims[1]).toMatchObject({ status: 'fulfilled', value: { amountOut: 80n, amountRemaining: 20n } }) + expect(swap).toHaveBeenCalledTimes(2) + expect(waitForSwapOutput).toHaveBeenCalledWith({ handle: handle(1), timeout: 600_000, pollingInterval: 3_000 }) + expect(claimSwapOutput).toHaveBeenCalledTimes(phase === 'wait' ? 1 : 2) + }) +}) + +// Validate entry-point guards in a separate process: importing the module in a +// unit test must not hide an accidental CLI execution or missing-key fallback. +describe('concurrent-swaps command', () => { + const script = fileURLToPath(new URL('./concurrent-swaps.ts', import.meta.url)) + it.each([ + { args: ['--help'], status: 0, message: 'without flags to preview' }, + { args: ['--execut'], status: 1, message: 'Usage:' }, + { args: [], status: 1, message: 'Set VEIL_E2E_PRIVATE_KEY' }, + ])('validates arguments before loading an account: $args', ({ args, status, message }) => { + const result = spawnSync('pnpm', ['exec', 'tsx', script, ...args], { + encoding: 'utf8', timeout: 15_000, + env: { ...process.env, VEIL_E2E_PRIVATE_KEY: '' }, + }) + expect(result.status, result.stderr).toBe(status) + expect(result.stdout + result.stderr).toContain(message) + }, 20_000) +}) diff --git a/examples/shield-swap/concurrent-swaps.ts b/examples/shield-swap/concurrent-swaps.ts new file mode 100644 index 00000000..1fa464be --- /dev/null +++ b/examples/shield-swap/concurrent-swaps.ts @@ -0,0 +1,198 @@ +/** + * Prepares six balanced USDCx records and swaps them for ETH concurrently. + * + * Preview: pnpm exec tsx examples/shield-swap/concurrent-swaps.ts + * Execute: pnpm exec tsx examples/shield-swap/concurrent-swaps.ts --execute + * + * Requires Node 22.13+, VEIL_E2E_PRIVATE_KEY, a funded testnet account and DEX + * access. Execution reshapes the entire available input-token inventory, then + * spends 0.1 USDCx per swap by default. See README.md for recovery instructions. + */ +import { mkdir, open, unlink } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { formatUnits, parseUnits, shieldSwapActions, type ShieldSwapActions, type SwapQuote } from '@provablehq/shield-swap-sdk' +import { swapFileStore } from '@provablehq/shield-swap-sdk/node' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' + +/** + * Submits swaps together and claims every successful output. + * + * All calls use the client's record journal and blinded identity store. A failed + * swap or claim leaves the other trades running, and no submission is retried. + * + * @param client Local signer configured with record management and persistent swap identities. + * @param offers Fresh quotes, one per trade. An empty list submits nothing. + * @returns Swap results, successful handles, and claim results. `claims[i]` belongs to `handles[i]`. + * @example + * const offer = await client.quote({ from: 'USDCx', to: 'ETH', amountIn: '0.1' }) + * const batch = await submitConcurrentSwaps(client, Array(6).fill(offer)) + * console.log(batch.swaps.map((result) => result.status)) + */ +export async function submitConcurrentSwaps( + client: Pick, + offers: readonly SwapQuote[], +) { + // Leave tokenRecord unset so the client can select another record if a + // concurrent swap reserves its first choice. Keep every submission result. + const swaps = await Promise.allSettled(offers.map((quote) => client.swap({ quote }))) + const handles = swaps.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + + // Each successful swap needs a separate claim. Waiting for all claim attempts + // ensures that one failure does not prevent the remaining payouts. + const claims = await Promise.allSettled(handles.map(async (handle) => { + await client.waitForSwapOutput({ handle, timeout: 600_000, pollingInterval: 3_000 }) + return client.claimSwapOutput({ handle }) + })) + return { swaps, handles, claims } +} + +/** + * Configures the testnet example without changing SDK defaults. + * @property privateKey Existing funded key. Defaults to VEIL_E2E_PRIVATE_KEY; never generated or saved. + * @property execute Enables inventory maintenance and swaps; defaults to false (preview). + * @property from Input symbol or token id. Defaults to USDCx. + * @property to Output symbol or token id. Defaults to ETH. + * @property amountIn Decimal input-token units per trade. Defaults to '0.1'. + * @property count Concurrent trades and target record count. Defaults to 6; accepts 2 through 16. + * @property directory Persistent recovery directory. Defaults to .veil/concurrent-swaps/testnet/
. + */ +export type ConcurrentSwapsOptions = { + privateKey?: string + execute?: boolean + from?: string + to?: string + amountIn?: string + count?: number + directory?: string +} + +/** + * Prepares private records and runs several swaps at once. + * Defaults to a preview. Execution uses delegated proving with FeeMaster payment + * and saves record reservations and swap handles for recovery after a restart. + * @param options Testnet account, trade terms and explicit execution opt-in. + * @returns The inventory plan and, after execution, the swap and claim results. + * @throws On invalid configuration, unresolved prior work, maintenance interruption or any failed trade. + * @example + * await concurrentSwaps() // Preview with VEIL_E2E_PRIVATE_KEY; submits nothing. + * // After inspecting the preview, execute with the same account and directory: + * await concurrentSwaps({ execute: true, count: 6, amountIn: '0.1' }) + */ +export async function concurrentSwaps(options: ConcurrentSwapsOptions = {}) { + const privateKey = options.privateKey ?? process.env.VEIL_E2E_PRIVATE_KEY + if (!privateKey) throw new Error('Set VEIL_E2E_PRIVATE_KEY to an existing funded testnet account with DEX access') + const { count = 6, from = 'USDCx', to = 'ETH', amountIn = '0.1' } = options + if (!Number.isInteger(count) || count < 2 || count > 16) throw new Error('count must be an integer from 2 through 16') + if (from === to) throw new Error('from and to must differ') + + const aleo = await loadNetwork('testnet') + const address = aleo.privateKeyToAccount(privateKey).address + const directory = resolve(options.directory ?? `.veil/concurrent-swaps/testnet/${address}`) + await mkdir(directory, { recursive: true, mode: 0o700 }) + // SQLite coordinates record reservations across processes. The JSON identity + // store coordinates only calls in this process, so hold a separate run lock. + // Run no other trader for this account while this example owns its stores. + const lockPath = resolve(directory, 'run.lock') + await createMarker(lockPath, 'Stop other runs before inspecting a stale lock') + let store: Awaited> | undefined + try { + store = await sqliteRecordInventoryStore(resolve(directory, 'inventory.sqlite')) + const { walletClient } = aleo.createAleoClient({ + privateKey, + networkUrl: 'https://edge.provable.com/api/v2', + provingMode: 'delegated', useFeeMaster: true, confirmationTimeout: 600_000, + records: aleo.createRemoteScanner({ waitForSync: true }), + // All calls through this client share the same record reservations. + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + }) + const client = walletClient.extend(shieldSwapActions({ + api: {}, blindedIdentities: swapFileStore(resolve(directory, 'blinded.json')), + })) + await client.authenticateShieldSwap() + + // Resolve known submissions before planning. Missing confirmations remain + // reserved; they are not evidence that a previous transaction failed. + const journal = await client.reconcileRecordInventory() + const unresolved = journal.filter((entry) => !['confirmed', 'rejected', 'cancelled'].includes(entry.status)) + if (unresolved.length) throw new Error(`Reconcile ${unresolved.length} unresolved operations in ${directory} before starting another batch`) + const unclaimed = await client.getUnclaimedSwaps() + if (unclaimed.swaps.length || unclaimed.unresolvable.length) throw new Error(`Recover previous swaps from ${directory}/blinded.json before starting another batch`) + + const token = await client.tokenData(from) + const minimum = parseUnits(amountIn, token.decimals) + if (minimum <= 0n) throw new Error('amountIn must be positive') + // Six independent inputs let six swaps start at once. This distributes the + // entire available token balance, leaving reservations untouched. + const plan = await client.planInventory({ + token: token.id, + target: { records: count, minRecordAmount: minimum, distribution: 'balanced', toleranceBps: 0 }, + maxTransactions: 24, + }) + console.log(`${count} concurrent ${from} -> ${to} swaps, ${amountIn} ${from} each; recovery: ${directory}`) + console.table(plan.steps.map((step) => ({ operation: step.kind, inputs: step.inputs.length, helper: step.joinProgram?.program ?? 'native' }))) + const preview = await client.quote({ from, to, amountIn, slippageBps: 50 }) + console.log(`Per swap: estimated ${formatUnits(preview.expectedOut, preview.to.decimals)} ${preview.to.symbol}; minimum ${formatUnits(preview.minOut, preview.to.decimals)} ${preview.to.symbol}`) + if (!options.execute) { + console.log('Preview only. Pass --execute to prepare records, submit swaps and claim outputs.') + return { plan } + } + + // Retain a marker on any failure or crash. A later invocation must not + // silently repeat an interrupted batch; recovery is described in README.md. + const pendingPath = resolve(directory, 'batch.pending') + await createMarker(pendingPath, 'Resolve the previous batch before removing this file') + const maintenance = await client.rebalanceRecordInventory({ plan, timeoutMs: 600_000, pollIntervalMs: 3_000 }) + if (maintenance.status !== 'complete') throw new Error(`Maintenance interrupted: ${maintenance.reason}; retain ${directory}`) + + // Quote after maintenance so joins and splits do not consume its lifetime. + // Sharing an immutable quote preserves the same output floor on every call; + // a quote does not reserve pool liquidity, so a trade can still fail. + const offer = await client.quote({ from, to, amountIn, slippageBps: 50 }) + const batch = await submitConcurrentSwaps(client, Array(count).fill(offer)) + for (const [index, result] of batch.claims.entries()) { + const handle = batch.handles[index]! + if (result.status === 'fulfilled') { + const claim = result.value + console.log(`Swap ${handle.transactionId}: received ${formatUnits(claim.amountOut, offer.to.decimals)} ${offer.to.symbol}; refunded ${formatUnits(claim.amountRemaining, offer.from.decimals)} ${offer.from.symbol}`) + } else { + console.error(`Recover the claim for ${handle.transactionId} from ${directory}`) + } + } + // Keep the pending marker until every swap and claim has succeeded. A later + // run must resolve an uncertain transaction before starting another batch. + const failures = [...batch.swaps, ...batch.claims].filter((result) => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map((result) => result.reason), `${failures.length} operations need recovery in ${directory}; do not repeat the batch`) + await unlink(pendingPath) + return { plan, ...batch } + } finally { + store?.close() + await unlink(lockPath) + } +} + +// Exclusive creation prevents a second run from replacing a lock or a pending +// batch marker. Only the caller that finishes the associated work removes it. +async function createMarker(path: string, instruction: string) { + const file = await open(path, 'wx', 0o600).catch((cause: unknown) => { + throw new Error(`Cannot create ${path}. ${instruction}.`, { cause }) + }) + await file.close() +} + +// Keep imports side-effect free for tests and callers that supply their own terms. +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const args = process.argv.slice(2) + if (args.length === 1 && args[0] === '--help') { + console.log('VEIL_E2E_PRIVATE_KEY must identify a funded testnet account. Run without flags to preview; --execute submits six 0.1 USDCx -> ETH swaps. See examples/shield-swap/README.md.') + } else if (args.some((arg) => arg !== '--execute') || args.length > 1) { + console.error('Usage: pnpm exec tsx examples/shield-swap/concurrent-swaps.ts [--execute | --help]') + process.exitCode = 1 + } else { + concurrentSwaps({ execute: args.includes('--execute') }).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Concurrent swaps failed; retain the recovery directory') + process.exitCode = 1 + }) + } +} diff --git a/examples/shield-swap/examples.test.ts b/examples/shield-swap/examples.test.ts index 20fdec8b..017a547a 100644 --- a/examples/shield-swap/examples.test.ts +++ b/examples/shield-swap/examples.test.ts @@ -4,6 +4,7 @@ import { quote } from './quote.js' import { balances } from './balances.js' import { swapHistory } from './swap-history.js' import { swap } from './swap.js' +import { concurrentSwaps } from './concurrent-swaps.js' import { mint } from './mint.js' import { liquidity } from './liquidity.js' import { rebalance } from './rebalance.js' @@ -38,6 +39,7 @@ describe.runIf(KEYED)('examples: reads that need an account', () => { it('quote', () => quote(), MINUTES) it('balances', () => balances(), MINUTES) it('swap-history', () => swapHistory(), MINUTES) + it('concurrent-swaps preview', () => concurrentSwaps(), MINUTES) }) describe.runIf(POSITION)('examples: reads that need a position', () => { @@ -55,3 +57,11 @@ describe.runIf(SPENDS)('examples: these move funds', () => { it('liquidity', () => liquidity(), MINUTES) it('rebalance', () => rebalance(), MINUTES) }) + +// This batch reshapes the whole available input inventory; opt in separately +// from the single-swap examples to avoid incidental maintenance and six trades. +describe.runIf(SPENDS && process.env.VEIL_CONCURRENT_EXAMPLE === '1')('examples: concurrent swaps', () => { + it('prepares six records and claims six concurrent swaps', async () => { + await concurrentSwaps({ execute: true }) + }, 3 * MINUTES) +}) diff --git a/llms.txt b/llms.txt index 8a5305a0..31e4ae1c 100644 --- a/llms.txt +++ b/llms.txt @@ -31,9 +31,27 @@ Core and Shield Swap expose `/agent` tool schemas and `/mcp` tools. Before opera - [Reading chain state](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/reading-chain-state.md): Public clients and mapping reads. - [Executing transactions](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/executing-transactions.md): Wallet clients and program transitions. - [Working with records](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/working-with-records.md): Private state, scanning, and decryption. +- [Record management](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/record-inventory.md): Shared record reservations, joins/splits, configuration, fees, and recovery. +- [Concurrent swaps](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/shield-swap.md#concurrent-swaps): Client setup, inventory preparation, simultaneous trades, and claims after partial failures. - [Transaction lifecycle](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/transaction-lifecycle.md): Submission, status, and settlement. - [Shield Swap trading runbooks](https://raw.githubusercontent.com/ProvableHQ/veil/main/packages/shield-swap/skills/SKILL.md): Required starting point for account setup, swaps, and liquidity. +## Concurrent swaps and high-frequency trading + +For a trading loop that submits swaps concurrently, start with the [concurrent swaps walkthrough](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/shield-swap.md#concurrent-swaps) and the [example commands](https://raw.githubusercontent.com/ProvableHQ/veil/main/examples/shield-swap/README.md). The examples use client-attached actions and a local signer. + +1. Enable `recordManagement` explicitly. Share the record store, `chainId`, and one blinded identity store instance across clients trading for the same account. Use persistent stores for recovery. Run one process per account with the bundled identity store; SQLite alone does not coordinate blinded identity counters across processes. +2. Prepare one sufficient input record per simultaneous trade. Use `client.planInventory()` to review joins and splits, then `client.rebalanceRecordInventory({ plan })` to execute that plan. For example, six simultaneous sales of 0.1 USDCx need six available records holding at least 0.1 USDCx each. +3. Fetch fresh quotes after inventory preparation. Quotes expire after 60 seconds. Submit the batch with `Promise.allSettled` and `client.swap({ quote })`. Omit `tokenRecord` for automatic local selection: a reservation conflict tries another available record before proving; it does not join records or retry a submitted swap. +4. Keep every successful handle. Call `client.waitForSwapOutput({ handle })`, then `client.claimSwapOutput({ handle })` for each trade. Inspect every swap and claim result; a batch can partially succeed, and claims can return both output tokens and unspent input. +5. Before the next batch, account for confirmed claims, refresh the inventory, and prepare replacement input records as needed. Bound concurrent work by available records and proving capacity. Reconcile uncertain transactions before submitting replacement trades. + +For high-frequency trading, concurrency lets independent trades progress while others await proofs or confirmation. The SDK does not guarantee a trading rate: proving, chain confirmation, scanner visibility, and API limits constrain throughput. A quote does not reserve liquidity, and trades in the same batch can affect one another's execution price. Stop submitting new work when inventory, claims, or transaction outcomes cannot be reconciled. + +- [Record management](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/record-inventory.md): Store configuration, maintenance plans, private fee records, and restart recovery. +- [Selection and recovery](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/shield-swap.md#selection-failures-and-recovery): Reservation diagram, partial batch results, and code to recover existing claims. +- [Shield Swap API](https://raw.githubusercontent.com/ProvableHQ/veil/main/site/docs/guides/shield-swap.md#the-shield-swap-api): API access, quoting, and the role of on-chain reads and transactions. + ## Optional - [Contributor instructions](https://raw.githubusercontent.com/ProvableHQ/veil/main/AGENTS.md): Repository navigation and contributor constraints. diff --git a/packages/core/README.md b/packages/core/README.md index bf8968c6..1219944f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -245,3 +245,78 @@ const client = createPublicClient({ }, })) ``` + +## Record management + +Wallet clients include methods to inspect private records, join or split them, +and prepare a target inventory. Record management also coordinates spends: +it reserves explicit inputs before proving and records transaction progress +for recovery. Reservations and recovery are off by default. + +Configure the wallet client once, then call its methods. The application +supplies the account, transport, and any local prover/scanner bindings: + +```ts +import { + createWalletClient, memoryRecordInventoryStore, + type WalletClientConfig, type RecordInventoryStore, +} from '@provablehq/veil-core' + +const sharedStore = memoryRecordInventoryStore() + +function createManagedWallet(config: WalletClientConfig, store: RecordInventoryStore = sharedStore) { + return createWalletClient({ + ...config, + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + }) +} + +async function previewCredits(config: WalletClientConfig) { + const client = createManagedWallet(config) + const inventory = await client.getRecordInventory({ asset: { program: 'credits.aleo' } }) + const plan = await client.planRecordInventory({ + asset: inventory.asset, + target: { records: 6, minRecordAmount: 1_000_000n, distribution: 'balanced' }, + maxTransactions: 24, + }) + return { client, inventory, plan } // Read-only; no transaction submitted. +} +``` + +After inspecting the plan, call `client.rebalanceRecordInventory({ plan })` and +check whether the result is `complete` or `interrupted`. For one join or split, +use `client.joinRecords` or `client.splitRecord`; these return before confirmation. +`client.autoJoin({ asset, minAmount })` selects and joins enough records for one +spend. `client.reconcileRecordInventory()` checks pending transactions after an +interruption without rebuilding them. +With `{ rebroadcast: true }`, a failed resubmission records +`lastRebroadcastError` and `rebroadcastAttempts` on its journal entry while +reconciliation continues for other entries. Uncertain spends keep their +reservations. See [recovery examples](../../site/docs/guides/record-inventory.md#recover-interrupted-work) +for inspection and retry steps. + +Every participating client must share the same store and `chainId`. The memory +store lasts for one process; pass a durable `RecordInventoryStore` for restart +recovery. Core has no database or Node dependency. A read or plan does not lock +records, and coordination does not start background maintenance. + +An asset requires only `program`. Optional `recordName` and `amountField` +override the selected record balance. Native credits use `credits.microcredits`; +other compatible records default to `amount`, then `microcredits`. More than two +records use a compatible batch join helper when available. Set `tokenJoin: false` +for native pairwise joins. + +Local managed writes support `privateFee: true`: an available credits record is +reserved with the execution inputs. A supplied reserved or insufficient fee +record is replaced when another eligible record exists. Failure to find one +stops the call before proving. The current wallet RPC protocol cannot pin a +managed private fee record. + +`recordActions()` adds these methods to custom clients. It does not enable +coordination, and its `store` and `maxFeeMicrocredits` options are ignored; put +those settings in the client's enabled `recordManagement` configuration. + +See the [record management guide](../../site/docs/guides/record-inventory.md) +for all defaults, durable storage, fee limits, helper selection, wallet grants, +and recovery examples. For concurrent trades, see +[Shield Swap setup](../shield-swap/README.md#concurrent-swaps). diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts new file mode 100644 index 00000000..5b2a0a66 --- /dev/null +++ b/packages/core/src/actions/records/autoJoin.ts @@ -0,0 +1,72 @@ +import { getRecordJoinPrograms } from '../../inventory/joinPrograms.js' +import type { Client } from '../../clients/createClient.js' +import type { InventoryResult, RecordAsset } from '../../inventory/types.js' +import { recordSettings, scopeOf } from '../../inventory/internal.js' +import { buildInventoryPlan } from '../../inventory/planner.js' +import { getRecordInventory } from './getRecordInventory.js' +import { rebalanceRecordInventory, type RebalanceRecordInventoryParameters } from './rebalanceRecordInventory.js' + +/** + * Configures automatic consolidation. + * @property asset Token to consolidate. + * @property minAmount Optional sufficient-record threshold in base units; omission consolidates all eligible records. + * @property maxTransactions Operation ceiling, defaults to 100. + */ +export type AutoJoinParameters = Omit & { + asset: RecordAsset; minAmount?: bigint; maxTransactions?: number +} + +/** + * Consolidates records to one record or joins only enough to cover a requested amount. + * Scans, signs and submits through the configured scanner and prover; existing sufficient records are preserved. + * @param client Account client with recordManagement.enabled for coordinated execution. + * @param params Asset, optional positive amount threshold and execution bounds. + * @returns Confirmed progress or a resumable interruption. + * @throws Before submission when available funds cannot cover the threshold. + * @example + * import type { WalletClient } from '@provablehq/veil-core' + * + * async function prepareOneCredit(client: WalletClient) { + * // The client must explicitly enable recordManagement with a shared store. + * const asset = { program: 'credits.aleo' } + * const minAmount = 1_000_000n // Microcredits; omission consolidates all records. + * const result = await client.autoJoin({ + * asset, minAmount, + * maxTransactions: 8, + * timeoutMs: 120_000, + * pollIntervalMs: 2_000, + * maxFeeMicrocredits: 10_000_000n, // Total network-fee ceiling for this run. + * }) + * console.log('Submitted transactions:', result.transactionIds) + * if (result.status === 'interrupted') { + * // An accepted step is not rolled back. Reconcile before deciding whether + * // a fresh scan and plan are safe; never blindly repeat an uncertain spend. + * await client.reconcileRecordInventory() + * throw new Error(result.reason ?? 'Consolidation interrupted') + * } + * // A sufficient existing record returns complete with zero steps. + * const inventory = await client.getRecordInventory({ asset }) + * return inventory.available.find((record) => record.amount >= minAmount) + * } + */ +export async function autoJoin(client: Client, params: AutoJoinParameters): Promise { + const inventory = await getRecordInventory(client, { asset: params.asset }) + if (params.minAmount !== undefined && params.minAmount <= 0n) throw new Error('minAmount must be positive') + let records = inventory.available + if (params.minAmount !== undefined) { + // Keep a sufficient existing record; otherwise select the largest records + // until their total covers the requested spend, leaving the rest untouched. + if (records.some((record) => record.amount >= params.minAmount!)) return { status: 'complete', transactionIds: [], completedSteps: 0 } + records = [...records].sort((a, b) => a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : a.id.localeCompare(b.id)) + let sum = 0n + records = records.filter((record) => { if (sum >= params.minAmount!) return false; sum += record.amount; return true }) + if (sum < params.minAmount) throw new Error('Insufficient available balance for autoJoin') + } + if (records.length <= 1) return { status: 'complete', transactionIds: [], completedSteps: 0 } + // Use the same planner and sequential executor as a rebalance so consolidation + // also waits for each dependent output and retains interrupted progress. + const joinPrograms = records.length > 2 ? await getRecordJoinPrograms(client, inventory.asset) : [] + const plan = buildInventoryPlan({ scope: scopeOf(client), asset: inventory.asset, joinPrograms, tokenJoin: recordSettings(client).tokenJoin, target: { records: 1 }, + inputs: records.map(({ id, amount }) => ({ id, amount })), maxTransactions: params.maxTransactions }) + return rebalanceRecordInventory(client, { ...params, plan }) +} diff --git a/packages/core/src/actions/records/getRecordInventory.ts b/packages/core/src/actions/records/getRecordInventory.ts new file mode 100644 index 00000000..f1f26690 --- /dev/null +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -0,0 +1,84 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryRecord, RecordAsset, ResolvedRecordAsset } from '../../inventory/types.js' +import { reservedRecordIds } from '../../inventory/internal.js' +import { requestRecords } from '../wallet/requestRecords.js' +import { decodeRecord, recordName, validateAsset } from './internal.js' + +/** + * Scopes an inventory scan to one supported asset. + * @property asset Program with optional record-name and amount-field overrides. + */ +export type GetRecordInventoryParameters = { asset: RecordAsset } + +/** + * Reports spendable records separately from records held by other operations. + * @property asset Resolved record name, amount field, and integer width. + * @property available Eligible, unreserved records. + * @property reserved Eligible records excluded by the shared journal. + * @property balance Unreserved balance in integer base units. + */ +export type GetRecordInventoryReturnType = { asset: ResolvedRecordAsset; available: InventoryRecord[]; reserved: InventoryRecord[]; balance: bigint } + +/** + * Scans all pages through the client's existing record provider or wallet. + * Verifies the asset interface and excludes non-token, bound and reserved records. + * @param client Account client; enabled record management excludes shared reservations. + * @param params Program and optional record/amount field selections to inspect. + * @returns Eligible records and their available balance; hits the network. + * @throws On missing wallet grants, unsupported assets, or a non-terminating scan. + * @example + * import type { WalletClient, RecordAsset } from '@provablehq/veil-core' + * + * async function inspectCredits(client: WalletClient) { + * // Amounts are integer microcredits. An enabled shared journal excludes + * // records held by concurrent operations from available and balance. + * const inventory = await client.getRecordInventory({ + * asset: { program: 'credits.aleo' }, + * }) + * console.log('Available microcredits:', inventory.balance.toString()) + * console.log('Available records:', inventory.available.length) + * console.log('Reserved records:', inventory.reserved.length) + * return inventory.available + * } + * + * // For a custom token, override the record and numeric field explicitly. + * // The deployed interface still has to support the selected record. + * const customAsset: RecordAsset = { + * program: 'custom_token.aleo', recordName: 'Balance', amountField: 'value', + * } + */ +export async function getRecordInventory(client: Client, params: GetRecordInventoryParameters): Promise { + // Verify the token interface before interpreting record fields, then read the + // shared reservations that must be excluded from available balances. + const capabilities = await validateAsset(client, params.asset) + const excluded = await reservedRecordIds(client) + const seen = new Map() + let previousPage: string | undefined + for (let page = 0; ; page++) { + if (page >= 10_000) throw new Error('Record scan exceeded its page limit') + const records = await requestRecords(client, { + program: params.asset.program, statusFilter: 'unspent', + // Wallets return the program's complete result. Filter locally below so + // withheld record-name grants cannot silently turn into empty inventory. + ...(client.account?.type === 'rpc' ? {} : { filter: { records: [recordName(capabilities.asset)], resultsPerPage: 1000, page } }), + }) + if (client.account?.type !== 'rpc' && records.length > 1000) throw new Error('Record provider ignored pagination; inventory is incomplete') + // Detect providers that repeat a full page instead of advancing; silently + // stopping would make a partial inventory look complete. + const pageKey = JSON.stringify(records.map((record) => record.commitment ?? record.tag ?? record.uid)) + if (records.length === 1000 && pageKey === previousPage) throw new Error('Record provider did not advance pagination; inventory is incomplete') + previousPage = pageKey + // Pages can overlap while the scanner advances. Deduplicate by nonce after + // checking ownership, amount, and whether the record can be used as a token. + for (const record of records) { + const parsed = decodeRecord(client, params.asset, record, capabilities) + if (parsed && !seen.has(parsed.id)) { seen.set(parsed.id, parsed) } + } + if (client.account?.type === 'rpc' || records.length < 1000) break + } + // Report held records separately so callers can distinguish a low available + // balance from funds already committed to another participating operation. + const available = [...seen.values()].filter((record) => !excluded.has(record.id)) + const reserved = [...seen.values()].filter((record) => excluded.has(record.id)) + return { asset: capabilities.asset, available, reserved, balance: available.reduce((sum, record) => sum + record.amount, 0n) } +} diff --git a/packages/core/src/actions/records/internal.ts b/packages/core/src/actions/records/internal.ts new file mode 100644 index 00000000..743e1ad2 --- /dev/null +++ b/packages/core/src/actions/records/internal.ts @@ -0,0 +1,127 @@ +import type { Client } from '../../clients/createClient.js' +import type { OwnedRecord, OwnedRecordEncrypted } from '../../types/records.js' +import type { TransactionInput } from '../../types/inputRequest.js' +import type { InventoryRecord, RecordAsset, ResolvedRecordAsset } from '../../inventory/types.js' +import { inventoryRecordId } from '../../inventory/internal.js' +import { parseProgram } from '../../contract/parseProgram.js' +import { checkProgramConformance } from '../../contract/arcConformance.js' +import { getCode } from '../public/getCode.js' +import { parseRecord } from '../../utils/records.js' + +/** Returns the credits split deduction, independently of network fees. @internal */ +export const splitDeduction = (asset: RecordAsset): bigint => asset.program === 'credits.aleo' ? 10_000n : 0n +/** Selects the resolved amount width, with native defaults for older plans. @internal */ +export const amountWidth = (asset: RecordAsset): ResolvedRecordAsset['amountType'] => + (asset as Partial).amountType ?? (asset.program === 'credits.aleo' || asset.amountField === 'microcredits' ? 'u64' : 'u128') +/** Selects the configured record name or the native token default. @internal */ +export const recordName = (asset: RecordAsset): string => asset.recordName ?? (asset.program === 'credits.aleo' ? 'credits' : 'Token') +/** Returns the largest representable amount for the selected record field. @internal */ +export const maxAmount = (asset: RecordAsset): bigint => (1n << BigInt(amountWidth(asset).slice(1))) - 1n + +/** Holds field metadata needed to reject incomplete wallet grants. @internal */ +export type AssetCapabilities = { asset: ResolvedRecordAsset; recipientBound: boolean; excluded: boolean } + +/** Resolves record defaults and verifies the deployed join/split interface. @internal */ +export async function validateAsset(client: Client, asset: RecordAsset): Promise { + if (!asset || !/^[a-z][a-z0-9_]*\.aleo$/.test(asset.program)) throw new Error('Invalid asset program') + for (const name of [asset.recordName, asset.amountField]) { + if (name !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('Invalid record or amount field name') + } + if (asset.standard !== undefined && !['credits', 'arc20', 'arc22'].includes(asset.standard)) throw new Error('Unsupported token standard') + if (asset.standard === 'credits' && asset.program !== 'credits.aleo') throw new Error('Native credits require credits.aleo') + if (asset.program === 'credits.aleo' && asset.standard && asset.standard !== 'credits') throw new Error('credits.aleo requires the credits standard') + // Native credits have a fixed record interface. Overrides still need to match it. + if (asset.program === 'credits.aleo') { + if ((asset.recordName && asset.recordName !== 'credits') || (asset.amountField && asset.amountField !== 'microcredits')) { + throw new Error('credits.aleo requires the credits record and microcredits amount field') + } + return { asset: { ...asset, standard: 'credits', recordName: 'credits', amountField: 'microcredits', amountType: 'u64' }, recipientBound: false, excluded: false } + } + const source = await getCode(client, { programId: asset.program }) + const program = parseProgram(source) + const join = program.functions.find(fn => fn.name === 'join') + const split = program.functions.find(fn => fn.name === 'split') + const joinInput = join?.inputs[0] + // An explicit record wins. Otherwise follow the native join input, which is + // Token for ARC programs and can have another name for custom programs. + const name = asset.recordName ?? (joinInput?.kind === 'record' ? joinInput.type : 'Token') + const record = program.records.find(record => record.name === name) + if (!record || program.id !== asset.program) throw new Error(`${asset.program} has an incompatible record join/split interface`) + const fieldName = asset.amountField ?? ['amount', 'microcredits'].find(name => record.fields.some(field => field.name === name)) + const field = record.fields.find(field => field.name === fieldName) + if (!field || !/^u(8|16|32|64|128)$/.test(field.type)) throw new Error('Record amount field must be a declared unsigned integer') + const sameRecord = (input: { kind: string; type?: string }) => input.kind === 'record' && input.type === name + if (record.fields.find(field => field.name === 'owner')?.type !== 'address' || + !join || join.inputs.length !== 2 || !join.inputs.every(sameRecord) || join.outputs.length !== 1 || !join.outputs.every(sameRecord) || + !split || split.inputs.length !== 2 || !sameRecord(split.inputs[0]!) || split.inputs[1]?.kind !== 'plaintext' || split.inputs[1].type !== field.type || + split.outputs.length !== 2 || !split.outputs.every(sameRecord)) { + throw new Error(`${asset.program} has an incompatible token join/split interface`) + } + // Detect standards only for their default Token balance. A custom field must + // never inherit eligibility for a helper that sums Token.amount instead. + let standard = asset.standard + if (!asset.recordName && !asset.amountField && standard) { + const report = checkProgramConformance(source, standard as 'arc20' | 'arc22') + const violations = report.violations.filter(violation => + ('fn' in violation && ['join', 'split'].includes(violation.fn)) || + ('name' in violation && ['join', 'split', 'Token'].includes(violation.name)) || + ('record' in violation && violation.record === 'Token')) + if (violations.length) throw new Error(`${asset.program} has an incompatible token join/split interface`) + } + if (!standard && name === 'Token' && fieldName === 'amount' && field.type === 'u128') { + standard = (['arc22', 'arc20'] as const).find(candidate => + checkProgramConformance(source, candidate).violations.every(violation => violation.kind === 'missing_view')) + } + return { asset: { ...asset, ...(standard ? { standard } : {}), recordName: name!, amountField: fieldName!, amountType: field.type as ResolvedRecordAsset['amountType'] }, + recipientBound: record.fields.some(field => field.name === 'recipient_bound'), + excluded: record.fields.some(field => ['token_id', 'sender', 'recipient'].includes(field.name)) } +} + +/** Extracts an eligible owned amount or rejects incomplete wallet grants. @internal */ +export function decodeRecord(client: Client, asset: RecordAsset, record: OwnedRecordEncrypted, capabilities?: AssetCapabilities): InventoryRecord | undefined { + asset = capabilities?.asset ?? asset + // Declared non-fungible/compliance fields remain excluded even when a wallet + // omits those fields from its grants. Plaintext checks below cover older plans. + if (capabilities?.excluded || record.programName !== asset.program || record.spent === true) return undefined + if (record.recordName && record.recordName !== recordName(asset)) return undefined + const plaintext = (record as Partial).recordPlaintext + const field = asset.amountField ?? (asset.program === 'credits.aleo' ? 'microcredits' : 'amount') + let amount: bigint + if (plaintext) { + // Decode the authoritative record body when available; metadata alone must + // not make another owner's record or a non-fungible record spendable. + const parsed = parseRecord(plaintext) + if (parsed.owner !== client.account?.address) return undefined + // Compliance records and registry records are not fungible ARC Token inventory. + if (parsed.fields.sender || parsed.fields.recipient || parsed.fields.token_id) return undefined + if (parsed.fields.recipient_bound?.value === true) return undefined + const value = parsed.fields[field] + if (!value || value.type.kind !== 'primitive' || value.type.primitive !== amountWidth(asset) || typeof value.value !== 'bigint') return undefined + amount = value.value + } else { + // Opaque wallet records need explicit grants for every eligibility check. + // Missing fields must fail instead of making an incomplete balance look empty. + if (record.recordName !== recordName(asset)) throw new Error('Inventory requires a recordName grant') + const fields = record.recordView?.fields + if (capabilities?.recipientBound && fields?.recipient_bound === undefined) throw new Error('Inventory requires a recipient_bound grant for this token') + if (fields?.recipient_bound === 'true' || fields?.recipient_bound === 'true.private') return undefined + const raw = fields?.[field]?.replace(/\.(private|public)$/, '') + const match = new RegExp(`^(\\d+)${amountWidth(asset)}$`).exec(raw ?? '') + if (!match) throw new Error(`Inventory requires a ${field} grant or record plaintext`) + amount = BigInt(match[1]!) + } + if (amount <= 0n || amount > maxAmount(asset)) return undefined + return { id: inventoryRecordId(record), amount, record } +} + +/** Pins a wallet record by uid or supplies local plaintext to the prover. @internal */ +export function recordInput(client: Client, asset: RecordAsset, record: InventoryRecord): TransactionInput { + // Wallet inputs identify a granted record by uid; local proving instead needs + // the plaintext literal and must never receive a wallet-only input request. + if (client.account?.type === 'rpc' && record.record.uid) { + return { type: 'record', program: asset.program, recordname: recordName(asset), uid: record.record.uid } + } + const plaintext = (record.record as Partial).recordPlaintext + if (!plaintext) throw new Error('Spending requires a wallet uid or record plaintext') + return plaintext +} diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts new file mode 100644 index 00000000..afc1c9f1 --- /dev/null +++ b/packages/core/src/actions/records/joinRecords.ts @@ -0,0 +1,82 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryRecord, RecordAsset, RecordJoinProgram } from '../../inventory/types.js' +import type { ProvingProgressHandler } from '../../types/proving.js' +import { writeContract } from '../wallet/writeContract.js' +import { decodeRecord, maxAmount, recordInput, validateAsset } from './internal.js' +import { management, recordSettings } from '../../inventory/internal.js' +import { validateTokenJoinFunction, validateTokenJoinRouter } from '../../inventory/tokenJoin.js' +import { getRecordJoinPrograms, validateRecordJoinFunction } from '../../inventory/joinPrograms.js' + +/** + * Supplies owned records for a native or configured batch join. + * @property asset Program with optional record-name and amount-field overrides. + * @property records Distinct records from getRecordInventory; exactly two natively, up to 16 with static helpers or 15 with dynamic ARC helpers. + * @property joinProgram Optional helper override. Omission selects an available helper for more than two records; false uses the native join. + * @property onProgress Optional awaited checkpoint callback; defaults to no reporting. + */ +export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryRecord[]; joinProgram?: RecordJoinProgram | false; onProgress?: ProvingProgressHandler } + +/** + * Joins records through the native transition or an available batch helper. + * Signs, proves and submits using the existing account and proving configuration. + * @param client Wallet client; recordManagement.enabled opts into shared reservations. + * @param params Distinct, same-owner records from getRecordInventory, within the configured batch limit. + * @returns Submitted transaction id; acceptance is asynchronous. + * @throws On incompatible records, overflow, contention or proving failure. + * @example + * import { waitForConfirmation, type WalletClient } from '@provablehq/veil-core' + * + * async function joinTwoCreditsRecords(client: WalletClient) { + * // Configure recordManagement.enabled with a shared store before calling + * // this from concurrent workers; the scan itself does not reserve inputs. + * const asset = { program: 'credits.aleo' } + * const inventory = await client.getRecordInventory({ asset }) + * const records = inventory.available.slice(0, 2) + * if (records.length !== 2) throw new Error('Two available credits records are required') + * + * const transactionId = await client.joinRecords({ + * asset, records, joinProgram: false, // Force the native two-record join. + * }) + * await waitForConfirmation(client, transactionId, 120_000) + * // Confirmation can precede scanner visibility. Use autoJoin when a + * // dependent operation needs the exact joined output to become readable. + * return transactionId + * } + * + * // To join more than two records in one transaction, omit joinProgram and + * // supply a count supported by an available helper. Use autoJoin when the + * // inventory needs several helper calls or native two-record joins. + */ +export async function joinRecords(client: Client, params: JoinRecordsParameters): Promise { + const capabilities = await validateAsset(client, params.asset) + const asset = capabilities.asset + const router = asset.program !== 'credits.aleo' ? recordSettings(client).tokenJoin : undefined + let helper = params.joinProgram + if (helper === undefined && router) { + const max = validateTokenJoinRouter(router) + await validateTokenJoinFunction(client, router, params.records.length) + helper = { program: router.program, inputMode: 'programIdentifierThenRecords', counts: Array.from({ length: max - 1 }, (_, i) => i + 2) } + } else if (helper === undefined && params.records.length > 2) { + helper = (await getRecordJoinPrograms(client, asset)).find(helper => helper.counts.includes(params.records.length)) + } + if (helper) await validateRecordJoinFunction(client, asset, helper, params.records.length) + else if (params.records.length !== 2) throw new Error('joinRecords requires two records without an available join helper; use autoJoin for multiple transactions') + // Re-decode the supplied records before proving; cached inventory amounts are + // not sufficient to establish ownership, eligibility, or distinct inputs. + const records = params.records.map((record) => decodeRecord(client, params.asset, record.record, capabilities)) + if (records.some((record) => !record) || new Set(records.map((record) => record?.id)).size !== records.length) { + throw new Error('Join requires distinct eligible records owned by this account') + } + const eligible = records as InventoryRecord[] + if (eligible.reduce((sum, record) => sum + record.amount, 0n) > maxAmount(asset)) throw new Error('Joined amount overflows the token width') + // Attribute helper calls to the source asset in the journal so maintenance + // budgets and cooldowns include both native and helper transactions. + const config = management(client) + const spendClient = helper && config ? Object.assign(Object.create(client), { + recordManagement: { ...config, joinAsset: { program: helper.program, assetProgram: asset.program } }, + }) as Client : client + const dynamic = helper && helper.inputMode === 'programIdentifierThenRecords' + return writeContract(spendClient, { program: helper ? helper.program : asset.program, function: helper ? `join_${records.length}` : 'join', + inputs: [...(dynamic ? [`'${asset.program.slice(0, -5)}'`] : []), ...eligible.map(record => recordInput(client, asset, record))], + ...(dynamic ? { imports: [asset.program] } : {}), onProgress: params.onProgress }) +} diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts new file mode 100644 index 00000000..54b2f10d --- /dev/null +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -0,0 +1,57 @@ +import { getRecordJoinPrograms } from '../../inventory/joinPrograms.js' +import type { Client } from '../../clients/createClient.js' +import type { InventoryPlan, InventoryTarget, RecordAsset } from '../../inventory/types.js' +import { recordSettings, scopeOf } from '../../inventory/internal.js' +import { buildInventoryPlan } from '../../inventory/planner.js' +import { getRecordInventory } from './getRecordInventory.js' + +/** + * Defines a read-only inventory planning request. + * @property asset Program with optional record-name and amount-field overrides. + * @property target Desired record count, minimum amount and distribution. + * @property maxTransactions Operation ceiling, defaults to 100, at most 10000. + */ +export type PlanRecordInventoryParameters = { asset: RecordAsset; target: InventoryTarget; maxTransactions?: number } + +/** + * Plans joins and splits against the current eligible record inventory. + * Reads the scanner and deployed interface; never signs or submits. + * @param client Account client used to scan and scope the plan. + * @param params Asset, target and transaction limit. + * @returns Plaintext-free dependency plan with intrinsic credits deductions. + * @throws When the target is impossible or exceeds the transaction limit. + * @example + * import type { WalletClient } from '@provablehq/veil-core' + * + * async function previewSixCreditsRecords(client: WalletClient) { + * const plan = await client.planRecordInventory({ + * asset: { program: 'credits.aleo' }, + * target: { + * records: 6, + * minRecordAmount: 1_000_000n, // Each record must hold at least one credit. + * distribution: 'balanced', + * toleranceBps: 0, + * }, + * maxTransactions: 24, + * }) + * // Planning reads the current inventory but neither reserves nor spends it. + * // Credits split deductions reduce the balance; network fees are separate. + * console.log('Intrinsic deduction in microcredits:', plan.deduction.toString()) + * console.table(plan.steps.map((step) => ({ + * operation: step.kind, + * inputs: step.inputs.length, + * outputs: step.outputs.length, + * helper: step.joinProgram?.program ?? 'native', + * }))) + * // Pass this unchanged plan to rebalanceRecordInventory after review. + * return plan + * } + */ +export async function planRecordInventory(client: Client, params: PlanRecordInventoryParameters): Promise { + const inventory = await getRecordInventory(client, { asset: params.asset }) + const joinPrograms = inventory.available.length > 2 ? await getRecordJoinPrograms(client, inventory.asset) : [] + // Retain only nonces and amounts in the plan; record plaintext stays in the + // scanner result and is fetched again when each step is executed. + return buildInventoryPlan({ ...params, asset: inventory.asset, joinPrograms, tokenJoin: recordSettings(client).tokenJoin, + scope: scopeOf(client), inputs: inventory.available.map(({ id, amount }) => ({ id, amount })) }) +} diff --git a/packages/core/src/actions/records/rebalanceRecordInventory.ts b/packages/core/src/actions/records/rebalanceRecordInventory.ts new file mode 100644 index 00000000..a8626885 --- /dev/null +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -0,0 +1,180 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryPlan, InventoryRecord, InventoryResult } from '../../inventory/types.js' +import { management, positiveInteger, scopeOf } from '../../inventory/internal.js' +import { buildInventoryPlan } from '../../inventory/planner.js' +import { getConfirmedTransaction } from '../public/getConfirmedTransaction.js' +import { requestRecords } from '../wallet/requestRecords.js' +import { getRecordInventory } from './getRecordInventory.js' +import { joinRecords } from './joinRecords.js' +import { splitRecord } from './splitRecord.js' +import { decodeRecord, validateAsset } from './internal.js' +import type { ProvingProgressHandler } from '../../types/proving.js' +import type { Transaction } from '../../types/transaction.js' +import type { ConfirmedTransaction } from '../../types/block.js' +import { inventoryOutputCommitments } from '../../inventory/tokenJoin.js' + +/** + * Configures bounded execution of a previously inspected plan. + * @property plan Plan from planRecordInventory, tied to its account and chain. + * @property timeoutMs Confirmation/output visibility timeout per step. Defaults to 120000. + * @property pollIntervalMs Poll interval, defaults to 1000 milliseconds. + * @property signal Optional cancellation signal; cancellation preserves submitted reservations. + * @property maxFeeMicrocredits Optional total network-fee budget for this run, excluding intrinsic split deductions. + * Wallet RPC accounts cannot enforce this limit; nonempty plans reject it before scanning. + */ +export type RebalanceRecordInventoryParameters = { + plan: InventoryPlan; timeoutMs?: number; pollIntervalMs?: number; signal?: AbortSignal; maxFeeMicrocredits?: bigint +} + +/** + * Executes a plan one confirmed transition at a time through existing write actions. + * Revalidates each input and matches scanner outputs to confirmed commitments. + * RPC execution requires commitment metadata on consumed records before submission. + * @param client Client with recordManagement.enabled for reservations and progress checkpoints. + * @param params Reviewed plan, timeouts, cancellation and optional fee budget. + * @returns Completion or interruption with all submitted transaction ids; never rolls back accepted steps. + * @throws On invalid/tampered plans, missing coordination, or a wallet fee ceiling on a nonempty plan, before submission. + * @example + * import type { WalletClient, InventoryPlan } from '@provablehq/veil-core' + * + * async function executeReviewedPlan(client: WalletClient, plan: InventoryPlan, signal?: AbortSignal) { + * // Use the same account, chain, and shared store that produced the plan. + * // Execution requires explicit recordManagement.enabled configuration. + * const result = await client.rebalanceRecordInventory({ + * plan, + * timeoutMs: 120_000, // Per step: confirmation plus scanner visibility. + * pollIntervalMs: 2_000, + * maxFeeMicrocredits: 10_000_000n, // Total network fees, excluding split deductions. + * signal, + * }) + * console.log('Confirmed steps:', result.completedSteps) + * console.log('Submitted transactions:', result.transactionIds) + * if (result.status === 'interrupted') { + * await client.reconcileRecordInventory() + * // Retain the journal: a timeout or cancellation does not release an + * // uncertain submission. Resolve it before scanning and planning again. + * console.error(result.reason) + * } + * return result + * } + */ +export async function rebalanceRecordInventory(client: Client, params: RebalanceRecordInventoryParameters): Promise { + const config = management(client) + if (!config) throw new Error('Inventory execution requires recordManagement.enabled') + // Rebuild instead of trusting caller-supplied transition amounts or dependencies. + const plan = buildInventoryPlan(params.plan) + const encode = (value: unknown) => JSON.stringify(value, (_, item) => typeof item === 'bigint' ? item.toString() : item) + if (encode(plan) !== encode(params.plan)) throw new Error('Inventory plan was modified; plan again') + if (plan.scope !== scopeOf(client)) throw new Error('Inventory plan belongs to another account or chain') + const timeout = params.timeoutMs ?? 120_000 + const interval = params.pollIntervalMs ?? 1000 + positiveInteger(timeout, 'timeoutMs', 2_147_483_647) + positiveInteger(interval, 'pollIntervalMs', 2_147_483_647) + if (params.maxFeeMicrocredits !== undefined && params.maxFeeMicrocredits < 0n) throw new Error('Fee budget cannot be negative') + // Wallets submit before exposing the proved fee. Reject unsupported ceilings + // before scanning; an already satisfied plan has no fees to constrain. + if (plan.steps.length && client.account?.type === 'rpc' && + (params.maxFeeMicrocredits !== undefined || config.maxFeeMicrocredits !== undefined)) { + throw new Error('Wallet accounts cannot enforce a fee ceiling before submission') + } + const result: InventoryResult = { status: 'complete', transactionIds: [], completedSteps: 0 } + // Initial inputs already use nonces; symbolic step outputs acquire their real + // nonces only after confirmation and scanner visibility. + const references = new Map(plan.inputs.map((input) => [input.id, input.id])) + const consumedInputs = new Set(plan.steps.flatMap((step) => step.inputs)) + let fees = 0n + const check = () => { + params.signal?.throwIfAborted() + if (scopeOf(client) !== plan.scope) throw new Error('Account or chain changed during inventory execution') + } + const pause = () => new Promise((resolve) => setTimeout(resolve, interval)) + try { + // Apply the same declared-field eligibility checks to confirmed outputs as + // to scanned inputs, including wallet grants on recipient-bound records. + const capabilities = plan.steps.length ? await validateAsset(client, plan.asset) : undefined + for (const [index, step] of plan.steps.entries()) { + check() + // Rescan before every step so a competing spend or changed amount stops + // execution before any dependent transaction is proved. + const inventory = await getRecordInventory(client, { asset: plan.asset }) + const inputs = step.inputs.map((id) => { + const record = inventory.available.find((item) => item.id === references.get(id)) + const expected = plan.inputs.find((item) => item.id === id) ?? plan.steps.flatMap((item) => item.outputs).find((item) => item.id === id) + if (!record || record.amount !== expected?.amount) throw new Error('Inventory changed; reconcile and plan again') + return record + }) + if (client.account?.type === 'rpc') { + // Check all initial records used by the plan before its first submission, + // then recheck each step's inputs if wallet grants change during execution. + const records = index === 0 ? inventory.available.filter((record) => consumedInputs.has(record.id)) : inputs + if (records.some(({ record }) => !(record.commitment ?? record.recordView?.fields['$commitment']))) { + throw new Error('Inventory rebalance requires a commitment or $commitment grant to identify confirmed outputs') + } + } + check() + // Apply the stricter of the remaining run budget and the per-transaction + // limit without changing the caller's client configuration. + const remaining = params.maxFeeMicrocredits === undefined ? undefined : params.maxFeeMicrocredits - fees + const perTransaction = config.maxFeeMicrocredits + const limit = remaining === undefined ? perTransaction : perTransaction === undefined || remaining < perTransaction ? remaining : perTransaction + const executionClient = Object.assign(Object.create(client), { recordManagement: { ...config, tokenJoin: plan.tokenJoin, maxFeeMicrocredits: limit } }) as Client + // Capture the id as soon as the transaction is prepared so an interrupted + // broadcast still returns enough information to reconcile the journal. + const onProgress: ProvingProgressHandler = (event) => { + if ('transactionId' in event && !result.transactionIds.includes(event.transactionId)) result.transactionIds.push(event.transactionId) + } + const transactionId = step.kind === 'join' + ? await joinRecords(executionClient, { asset: plan.asset, records: inputs, joinProgram: step.joinProgram ?? (plan.tokenJoin ? undefined : false), onProgress }) + : await splitRecord(executionClient, { asset: plan.asset, record: inputs[0]!, amount: step.amount!, onProgress }) + if (!result.transactionIds.includes(transactionId)) result.transactionIds.push(transactionId) + const journal = await config.store.list(plan.scope) + const entry = journal.find((item) => item.transactionId === transactionId) + if (!entry) throw new Error('Submitted transaction is missing its reservation') + fees += BigInt(entry.feeMicrocredits ?? '0') + const deadline = Date.now() + timeout + let confirmed: ConfirmedTransaction | undefined + // A missing transaction may still be propagating. Retain its reservation + // while polling and stop the plan if acceptance cannot be established. + while (!confirmed) { + check() + if (Date.now() >= deadline) throw new Error(`Timed out confirming ${transactionId}; reservation retained`) + try { confirmed = await getConfirmedTransaction(client, { id: transactionId }) } + catch (error) { if ((error as { status?: number }).status !== 404) throw error } + if (!confirmed) await pause() + } + if (confirmed.status !== 'accepted') { + if (confirmed.status === 'rejected') await config.store.update(plan.scope, entry.id, { status: 'rejected', transaction: undefined }) + throw new Error(`Transaction ${transactionId} was not accepted`) + } + await config.store.update(plan.scope, entry.id, { status: 'confirmed', transaction: undefined }) + const commitments = inventoryOutputCommitments(confirmed.transaction as Transaction, plan.asset, step, plan.tokenJoin) + if (commitments.length !== step.outputs.length) throw new Error('Unexpected confirmed inventory output count') + // The scanner must return these exact outputs, not an unrelated new deposit. + let outputs: InventoryRecord[] = [] + while (outputs.length !== commitments.length) { + check() + if (Date.now() >= deadline) throw new Error(`Scanner has not indexed ${transactionId}; reconcile before continuing`) + const records = await requestRecords(client, { program: plan.asset.program, statusFilter: 'unspent', + ...(client.account?.type === 'rpc' ? {} : { filter: { commitments } }) }) + outputs = commitments.flatMap((commitment) => { + const record = records.find((item) => (item.commitment ?? item.recordView?.fields['$commitment']) === commitment) + const decoded = record && decodeRecord(client, plan.asset, record, capabilities) + return decoded ? [decoded] : [] + }) + if (outputs.length !== commitments.length) await pause() + } + // Bind each planned output to the exact scanned nonce and amount before + // making it available to the next dependent step. + outputs.forEach((output, index) => { + if (output.amount !== step.outputs[index]!.amount) throw new Error('Confirmed output amount differs from the plan') + references.set(step.outputs[index]!.id, output.id) + }) + result.completedSteps++ + } + } catch (error) { + // Earlier accepted steps remain on-chain; return their progress and let + // reconciliation resolve any pending spend before a fresh plan is built. + return { ...result, status: 'interrupted', reason: error instanceof Error ? error.message : String(error) } + } + return result +} diff --git a/packages/core/src/actions/records/reconcileRecordInventory.ts b/packages/core/src/actions/records/reconcileRecordInventory.ts new file mode 100644 index 00000000..03e11cea --- /dev/null +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -0,0 +1,92 @@ +import type { Client } from '../../clients/createClient.js' +import type { RecordSpend } from '../../inventory/types.js' +import { management, scopeOf } from '../../inventory/internal.js' +import { getConfirmedTransaction } from '../public/getConfirmedTransaction.js' + +/** + * Configures pending transaction recovery. + * @property rebroadcast Allows resubmission of the exact proved transaction; defaults to false. + */ +export type ReconcileRecordInventoryParameters = { rebroadcast?: boolean } + +/** + * Reconciles journal entries against chain acceptance after an interruption. + * Missing transactions remain reserved; optional rebroadcast reuses the same proof/id. + * Failed rebroadcasts are recorded per entry so later transactions can still be reconciled. + * @param client Client with recordManagement.enabled and the original durable store. + * @param params Optional exact-transaction rebroadcast, disabled by default. + * @returns Current scoped journal with rebroadcast attempt counts and any lastRebroadcastError. + * Reads the chain and optionally resubmits transactions; failed rebroadcasts retain their inputs. + * @throws On storage errors or confirmation lookup errors other than transaction-not-found (HTTP 404). + * @example + * import type { WalletClient } from '@provablehq/veil-core' + * + * async function inspectRecovery(client: WalletClient) { + * // Reopen the original store and enable recordManagement on the same account + * // and chain. The default only reads confirmations; it never rebroadcasts. + * const journal = await client.reconcileRecordInventory() + * const unresolved = journal.filter((entry) => + * entry.status !== 'confirmed' && entry.status !== 'rejected' && entry.status !== 'cancelled', + * ) + * console.table(unresolved.map((entry) => ({ + * operation: entry.id, transactionId: entry.transactionId, status: entry.status, + * attempts: entry.rebroadcastAttempts ?? 0, error: entry.lastRebroadcastError, + * }))) + * return unresolved // Missing transactions remain reserved. + * } + * + * async function rebroadcastSavedTransactions(client: WalletClient) { + * // Choose this explicitly after inspection. Only saved, proved transactions + * // are rebroadcast, with their original proof and id; nothing is rebuilt. + * await client.reconcileRecordInventory({ rebroadcast: true }) + * // Broadcast acceptance is not confirmation. Inspect again on a later poll. + * return inspectRecovery(client) + * } + */ +export async function reconcileRecordInventory(client: Client, params: ReconcileRecordInventoryParameters = {}): Promise { + const config = management(client) + if (!config) throw new Error('Reconciliation requires recordManagement.enabled with a shared store') + const scope = scopeOf(client) + for (const entry of await config.store.list(scope)) { + if (entry.status === 'confirmed' || entry.status === 'rejected' || entry.status === 'cancelled') continue + if (!entry.transactionId) continue // An uncertain wallet response needs external reconciliation. + let missing = false + let result: Awaited> | undefined + try { + // Only a definitive chain result changes the reservation state. A 404 + // does not prove that a prepared or submitted transaction was rejected. + result = await getConfirmedTransaction(client, { id: entry.transactionId }) + } catch (error) { + if ((error as { status?: number }).status !== 404) throw error + missing = true + } + if (!missing) { + // Keep checkpoint writes outside the lookup catch so a storage 404 cannot + // be mistaken for a missing transaction and trigger another broadcast. + if (result?.status === 'accepted' || result?.status === 'rejected') { + await config.store.update(scope, entry.id, { status: result.status === 'accepted' ? 'confirmed' : 'rejected', + transaction: undefined, lastRebroadcastError: undefined }) + continue + } + throw new Error('Unexpected transaction confirmation response') + } + // Recovery reuses the saved proof and id; rebuilding could create a second + // transaction while the original submission is still unresolved. + if (missing && params.rebroadcast && entry.transaction) { + const rebroadcastAttempts = (entry.rebroadcastAttempts ?? 0) + 1 + try { + const id = await client.request({ method: 'sendTransaction', params: { transaction: JSON.stringify(entry.transaction) } }) + if (id !== entry.transactionId) throw new Error('Rebroadcast returned a different transaction id') + } catch (error) { + // A broadcast failure is not a chain rejection. Keep the saved proof and + // all inputs, but let later entries reach their own confirmation checks. + await config.store.update(scope, entry.id, { rebroadcastAttempts, + lastRebroadcastError: error instanceof Error ? error.message : String(error) }) + continue + } + // Persist outside the broadcast catch: storage failures must still abort. + await config.store.update(scope, entry.id, { status: 'submitted', rebroadcastAttempts, lastRebroadcastError: undefined }) + } + } + return config.store.list(scope) +} diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts new file mode 100644 index 00000000..15d26dd6 --- /dev/null +++ b/packages/core/src/actions/records/splitRecord.ts @@ -0,0 +1,52 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryRecord, RecordAsset } from '../../inventory/types.js' +import type { ProvingProgressHandler } from '../../types/proving.js' +import { writeContract } from '../wallet/writeContract.js' +import { amountWidth, decodeRecord, recordInput, splitDeduction, validateAsset } from './internal.js' + +/** + * Supplies an owned record for a native split. + * @property asset Program with optional record-name and amount-field overrides. + * @property record Source record from getRecordInventory. + * @property amount Positive first output in integer base units (bounded by the declared field width). + * @property onProgress Optional awaited checkpoint callback; defaults to no reporting. + */ +export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecord; amount: bigint; onProgress?: ProvingProgressHandler } + +/** + * Splits a token record into two positive records through its native transition. + * Credits subtract 10,000 microcredits from the second output; signs and broadcasts. + * @param client Wallet client; recordManagement.enabled opts into shared reservations. + * @param params Source record and positive first output amount (bounded by the declared field width). + * @returns Submitted transaction id, before confirmation. + * @throws When either output would be empty or inputs are incompatible. + * @example + * import { waitForConfirmation, type WalletClient } from '@provablehq/veil-core' + * + * async function splitOneCredit(client: WalletClient) { + * const asset = { program: 'credits.aleo' } + * const amount = 1_000_000n // One credit, expressed in microcredits. + * const deduction = 10_000n // Native credits split deduction, separate from fees. + * const inventory = await client.getRecordInventory({ asset }) + * const record = inventory.available.find((item) => item.amount > amount + deduction) + * if (!record) throw new Error('No record can produce two positive outputs') + * + * // The first output holds amount; the second holds this remainder. + * console.log('Second output in microcredits:', (record.amount - amount - deduction).toString()) + * const transactionId = await client.splitRecord({ asset, record, amount }) + * await waitForConfirmation(client, transactionId, 120_000) + * // Rescan after the scanner indexes the outputs before selecting either. + * return transactionId + * } + */ +export async function splitRecord(client: Client, params: SplitRecordParameters): Promise { + const capabilities = await validateAsset(client, params.asset) + const asset = capabilities.asset + const record = decodeRecord(client, params.asset, params.record.record, capabilities) + // Account for the intrinsic credits deduction when checking the second output; + // both outputs must remain positive after the native split executes. + if (!record || typeof params.amount !== 'bigint' || params.amount <= 0n || + params.amount + splitDeduction(asset) >= record.amount) throw new Error('Split must produce two positive eligible records') + return writeContract(client, { program: params.asset.program, function: 'split', + inputs: [recordInput(client, asset, record), `${params.amount}${amountWidth(asset)}`], onProgress: params.onProgress }) +} diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index 3776ac6b..1363fee2 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -1,3 +1,6 @@ +import { management } from '../../inventory/internal.js' +import { manageRecordSpend, withoutManagement } from '../../inventory/spending.js' +import type { ProvingProgressHandler } from '../../types/proving.js' import { AccountNotFoundError, ProvingNotConfiguredError } from '../../errors/errors.js' import type { Client } from '../../clients/createClient.js' import type { RawExecuteResult } from '../../types/proving.js' @@ -19,9 +22,12 @@ import { extractTransitions } from '../../utils/extractTransitions.js' * the RPC path. * @property privateFee Pay the fee from a private record instead of the public * credits balance. Defaults to `false`. The fee record is resolved via the wallet - * client's record provider; callers do not supply one. + * client's record provider unless feeRecord is supplied. * @property programSource Optional program source to prove against instead of * fetching it from the chain. Local-proving path only. + * @property onProgress Optional awaited proving/submission checkpoints. Defaults to no reporting. + * @property feeRecord Optional private credits record plaintext; used only with privateFee. + * Managed local writes replace reserved, insufficient, or unavailable records with an eligible one. * @property imports Program id → source for programs reached via dynamic dispatch * that can't be discovered statically. Static imports are auto-discovered. The RPC * path forwards only the ids; the wallet resolves the sources itself. @@ -32,8 +38,10 @@ export type ExecuteContractParameters = { inputs: TransactionInput[] fee?: bigint privateFee?: boolean + feeRecord?: string programSource?: string imports?: Record + onProgress?: ProvingProgressHandler } /** Transaction id plus per-transition outputs of the confirmed execution. */ @@ -60,6 +68,7 @@ export type ExecuteContractReturnType = RawExecuteResult * @param client Wallet client with an account attached. * @param params Program, function, inputs, and fee/source/import options. * @returns The transaction id with the outputs of every transition, once the transaction is accepted. + * @throws RecordReservationConflictError if enabled management cannot acquire an execution input before proving. * @throws AccountNotFoundError if the client has no signing account. * @throws ProvingNotConfiguredError if the account is local and the proving config lacks `execute`. * @throws If confirmation polling times out or the transaction is rejected. @@ -75,12 +84,20 @@ export async function executeContract( client: Client, params: ExecuteContractParameters, ): Promise { + if (management(client)) { + // Copy the input list and its request objects before awaiting reservations. + // The inner call journals progress without reserving the inputs again. + const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } + return manageRecordSpend(client, snapshot, (onProgress, feeRecord) => + executeContract(withoutManagement(client, feeRecord), { ...snapshot, onProgress })) + } const account = client.account if (!account || !('sign' in account)) { throw new AccountNotFoundError() } if (account.type === 'rpc') { + if (params.feeRecord) throw new Error('This wallet protocol cannot pin a supplied fee record') // 1. Submit via wallet — adapter transport returns the tx id. Param shape mirrors // writeContract: the wallet handles fee + program-source resolution internally, so // `fee` and `programSource` on this action don't translate to the wire call. @@ -95,9 +112,13 @@ export async function executeContract( }, }) as string + await params.onProgress?.({ type: 'transaction-submitted', transactionId: txId }) + // 2. Wait for chain confirmation via the same transport. const confirmedTx = await waitForConfirmation(client, txId) + await params.onProgress?.({ type: 'transaction-confirmed', transactionId: txId }) + // 3. Walk transitions; no decryptor (see docstring). const { transitions, outputs } = extractTransitions(confirmedTx) @@ -108,6 +129,8 @@ export async function executeContract( // Local proving resolves no wallet-side inputs — only encoded strings. assertNoInputRequests(params.inputs) + // The prover builds, submits, and confirms this transaction. Forward the + // callback so it can save progress before continuing to the next stage. if (client.proving?.execute) { return client.proving.execute({ programName: params.program, @@ -115,8 +138,10 @@ export async function executeContract( inputs: params.inputs, fee: params.fee ?? 0n, privateFee: params.privateFee, + ...(params.feeRecord ? { feeRecord: params.feeRecord } : {}), programSource: params.programSource, programImports: params.imports, + ...(params.onProgress ? { onProgress: params.onProgress } : {}), }) } diff --git a/packages/core/src/actions/wallet/writeContract.ts b/packages/core/src/actions/wallet/writeContract.ts index aca0b8d8..40230d83 100644 --- a/packages/core/src/actions/wallet/writeContract.ts +++ b/packages/core/src/actions/wallet/writeContract.ts @@ -1,3 +1,5 @@ +import { management } from '../../inventory/internal.js' +import { manageRecordSpend, withoutManagement } from '../../inventory/spending.js' import { AccountNotFoundError, ProvingNotConfiguredError } from '../../errors/errors.js' import type { Client } from '../../clients/createClient.js' import { assertNoInputRequests } from '../../types/inputRequest.js' @@ -14,7 +16,9 @@ import type { ProvingProgressHandler } from '../../types/proving.js' * InputRequests require a wallet (RPC) account — the local-proving path rejects them. * @property privateFee Pay the fee from a private record instead of the public credits * balance. Defaults to `false`. The fee record is resolved via the wallet client's - * record provider; callers do not supply one. + * record provider unless feeRecord is supplied. + * @property feeRecord Optional private credits record plaintext; used only with privateFee. + * Managed local writes replace reserved, insufficient, or unavailable records with an eligible one. * @property imports Names of programs reached via dynamic dispatch that the prover or * wallet can't discover statically. Static imports in the program's `import` block are * auto-discovered. @@ -25,6 +29,7 @@ export type WriteContractParameters = { function: string inputs: TransactionInput[] privateFee?: boolean + feeRecord?: string imports?: string[] onProgress?: ProvingProgressHandler | undefined } @@ -52,6 +57,7 @@ export type WriteContractReturnType = string * @param client Wallet client with an account attached. * @param params Program, function, inputs, and fee/import options. * @returns The transaction id to poll with `transactionStatus`. + * @throws RecordReservationConflictError if enabled management cannot acquire an execution input before proving. * @throws AccountNotFoundError if the client has no signing account. * @throws ProvingNotConfiguredError if the account is local and the client has no proving config. * @throws If `inputs` contains InputRequest objects on the local-proving path. @@ -67,12 +73,20 @@ export async function writeContract( client: Client, params: WriteContractParameters, ): Promise { + if (management(client)) { + // Copy the input list and its request objects before awaiting reservations. + // The inner call journals progress without reserving the inputs again. + const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } + return manageRecordSpend(client, snapshot, (onProgress, feeRecord) => + writeContract(withoutManagement(client, feeRecord), { ...snapshot, onProgress })) + } const account = client.account if (!account || !('sign' in account)) { throw new AccountNotFoundError() } if (account.type === 'rpc') { + if (params.feeRecord) throw new Error('This wallet protocol cannot pin a supplied fee record') // RPC account — wallet handles proving, signing, and broadcasting const transactionId = await client.request({ method: 'executeTransaction', @@ -102,10 +116,13 @@ export async function writeContract( functionName: params.function, inputs: params.inputs, privateFee: params.privateFee, + ...(params.feeRecord ? { feeRecord: params.feeRecord } : {}), imports: params.imports, ...(params.onProgress ? { onProgress: params.onProgress } : {}), }) + // Await the checkpoint before broadcast so a persistent store can save the + // proved transaction, or reject the write if its fee exceeds the limit. await params.onProgress?.({ type: 'transaction-prepared', transactionId: tx.id, transaction: tx }) const transactionId = await client.request({ diff --git a/packages/core/src/clients/createClient.ts b/packages/core/src/clients/createClient.ts index 17a542f5..f36fba2a 100644 --- a/packages/core/src/clients/createClient.ts +++ b/packages/core/src/clients/createClient.ts @@ -1,6 +1,7 @@ import type { AnyAccount } from '../types/account.js' import type { ProvingConfig } from '../types/proving.js' -import type { RecordProvider } from '../types/records.js' +import type { RecordManagementConfig } from '../inventory/types.js' +import { configureRecordManagement } from '../inventory/internal.js' import type { Transport } from '../types/transport.js' import { uid as createUid } from '../utils/uid.js' @@ -13,6 +14,8 @@ import { uid as createUid } from '../utils/uid.js' * @property name Optional human-readable name for the client. Defaults to `"Client"`. * @property proving Optional proving configuration selecting where zero-knowledge * proofs are generated (wallet, local, or devnode). Omit for read-only clients. + * @property recordManagement Optional record reservations and recovery. Defaults to disabled; + * omission or enabled: false leaves writes unchanged. * @property transport Transport that carries requests to the network. */ export type ClientConfig = { @@ -20,6 +23,7 @@ export type ClientConfig = { key?: string | undefined name?: string | undefined proving?: ProvingConfig | undefined + recordManagement?: RecordManagementConfig | undefined transport: Transport } @@ -35,6 +39,7 @@ export type ClientConfig = { * @property name Human-readable name for the client. * @property proving Proving configuration, or `undefined` when the client cannot * produce transactions. + * @property recordManagement Record reservation and recovery settings, or undefined when omitted. * @property request Transport request function, called to reach the network. * @property transport The transport backing `request`. * @property uid Unique identifier for this client instance. @@ -44,6 +49,7 @@ type ClientBase = { key: string name: string proving: ProvingConfig | undefined + recordManagement?: RecordManagementConfig | undefined request: Transport['request'] transport: Transport uid: string @@ -124,6 +130,7 @@ export function createClient(config: ClientConfig): Client { key = 'base', name = 'Client', proving, + recordManagement, transport, } = config @@ -134,6 +141,8 @@ export function createClient(config: ClientConfig): Client { key, name, proving, + // Resolve record settings before action closures capture the client. + recordManagement: configureRecordManagement(recordManagement, transport), request: transport.request, transport, uid, diff --git a/packages/core/src/clients/createPublicClient.ts b/packages/core/src/clients/createPublicClient.ts index 2cb8cbbc..40cee673 100644 --- a/packages/core/src/clients/createPublicClient.ts +++ b/packages/core/src/clients/createPublicClient.ts @@ -11,7 +11,7 @@ import { publicActions, type PublicActions } from './decorators/public.js' * @property key Optional identifier for the client's type. Defaults to `"public"`. * @property name Optional human-readable name. Defaults to `"Public Client"`. */ -export type PublicClientConfig = Omit & { +export type PublicClientConfig = Omit & { key?: string | undefined name?: string | undefined } diff --git a/packages/core/src/clients/createWalletClient.ts b/packages/core/src/clients/createWalletClient.ts index ada74e9b..748b7e53 100644 --- a/packages/core/src/clients/createWalletClient.ts +++ b/packages/core/src/clients/createWalletClient.ts @@ -1,25 +1,43 @@ import type { RpcAccount, LocalAccount } from '../types/account.js' import type { ProvingConfig } from '../types/proving.js' import type { RecordProvider } from '../types/records.js' +import type { RecordManagementConfig } from '../inventory/types.js' import type { Transport } from '../types/transport.js' import { createClient, type Client } from './createClient.js' import { walletActions, type WalletActions } from './decorators/wallet.js' -/** Config for RPC account — wallet handles proving and records */ +/** + * Configures an RPC wallet that handles proving and records. + * @property account Connected wallet account. + * @property transport Transport used for wallet requests and chain reads. + * @property recordManagement Optional shared record reservations and recovery. Defaults to disabled. + * @property key Optional client identifier. Defaults to wallet. + * @property name Optional display name. Defaults to Wallet Client. + */ export type RpcWalletClientConfig = { account: RpcAccount + recordManagement?: RecordManagementConfig transport: Transport key?: string | undefined name?: string | undefined // No recordProvider — RPC wallets handle records via the wallet adapter } -/** Config for local account — must provide a proving config (use mode: 'devnode' for devnode clients) */ +/** + * Configures a local signing account with an explicit prover. + * @property account Account holding the local signing key. + * @property transport Transport used for chain reads and submission. + * @property proving Prover configuration; use mode: devnode for a local node. + * @property recordProvider Optional record source, required for requestRecords. + * @property recordManagement Optional shared record reservations and recovery. Defaults to disabled. + * @property key Optional client identifier. Defaults to wallet. + * @property name Optional display name. Defaults to Wallet Client. + */ export type LocalWalletClientConfig = { account: LocalAccount + recordManagement?: RecordManagementConfig transport: Transport proving: ProvingConfig - /** Record provider for fetching records. Required for requestRecords with a local account. */ recordProvider?: RecordProvider key?: string | undefined name?: string | undefined diff --git a/packages/core/src/clients/decorators/wallet.ts b/packages/core/src/clients/decorators/wallet.ts index 65fe1986..5fabcc1c 100644 --- a/packages/core/src/clients/decorators/wallet.ts +++ b/packages/core/src/clients/decorators/wallet.ts @@ -11,6 +11,7 @@ import { transactionStatus, type TransactionStatusParameters, type TransactionSt import { switchChain, type SwitchChainParameters, type SwitchChainReturnType } from '../../actions/wallet/switchChain.js' import { requestTransactionHistory, type RequestTransactionHistoryParameters, type RequestTransactionHistoryReturnType } from '../../actions/wallet/requestTransactionHistory.js' import { getChainId, type GetChainIdReturnType } from '../../actions/wallet/getChainId.js' +import { recordActionMethods } from '../../inventory/recordActions.js' import type { Client } from '../createClient.js' /** @@ -21,7 +22,7 @@ import type { Client } from '../createClient.js' * Several names are aliases that match Aleo wallet adapter terminology. See each * action's own documentation for parameters, return value, and behavior. */ -export type WalletActions = { +export type WalletActions = ReturnType & { sendTransaction: (params: SendTransactionParameters) => Promise writeContract: (params: WriteContractParameters) => Promise /** Alias for writeContract — matches the Aleo wallet adapter spec (submit and return a transaction id). */ @@ -68,6 +69,8 @@ export type WalletActions = { */ export function walletActions(client: Client): WalletActions { return { + // Include record methods on every wallet; client configuration controls reservations. + ...recordActionMethods(client), sendTransaction: (params) => sendTransaction(client, params), writeContract: (params) => writeContract(client, params), executeTransaction: (params) => writeContract(client, params), diff --git a/packages/core/src/errors/errors.ts b/packages/core/src/errors/errors.ts index 75306deb..1bd465c6 100644 --- a/packages/core/src/errors/errors.ts +++ b/packages/core/src/errors/errors.ts @@ -172,6 +172,26 @@ export class DuplicateTransactionError extends BaseError { } } +/** + * Reports an input reservation conflict before signing or proving starts. + * The rejected attempt owns no reservation and has submitted no transaction. + * Callers may select different inputs; proving and broadcast failures never use this error. + * @property recordIds Execution input nonces involved in the failed acquisition, excluding fee-only contention. + * @example + * if (error instanceof RecordReservationConflictError) { + * console.log('Select another available input record') + * } + */ +export class RecordReservationConflictError extends BaseError { + readonly recordIds: readonly string[] + + constructor(recordIds: readonly string[]) { + super('Record inventory changed: an input is already reserved or spent') + this.name = 'RecordReservationConflictError' + this.recordIds = [...recordIds] + } +} + /** A record was double-spent — the serial number has already been consumed */ export class RecordSpentError extends BaseError { constructor(message: string, options?: ErrorOptions) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5555bbc..ee76191e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export type { ProvingConfig, GenerateFreezelistProofParameters, BuildTransactionOptions, + EstimateExecutionFeeOptions, BuildDeploymentOptions, SimulateOptions, ExecuteOptions, @@ -117,6 +118,7 @@ export { InvalidTransactionError, DuplicateTransactionError, RecordSpentError, + RecordReservationConflictError, OutputIdCollisionError, BroadcastError, TransactionTimeoutError, @@ -263,3 +265,17 @@ export { } from './actions/public/checkArcConformance.js' export { isArc20, type IsArc20Parameters, type IsArc20ReturnType } from './actions/public/isArc20.js' export { isArc22, type IsArc22Parameters, type IsArc22ReturnType } from './actions/public/isArc22.js' + +// Record inventory management. +export type * from './inventory/types.js' +export { recordActions } from './inventory/recordActions.js' +export { recordJoinPrograms } from './inventory/joinPrograms.js' +export { memoryRecordInventoryStore } from './inventory/store.js' +export { inventoryRecordId, reservedRecordIds } from './inventory/internal.js' +export { getRecordInventory, type GetRecordInventoryParameters, type GetRecordInventoryReturnType } from './actions/records/getRecordInventory.js' +export { planRecordInventory, type PlanRecordInventoryParameters } from './actions/records/planRecordInventory.js' +export { rebalanceRecordInventory, type RebalanceRecordInventoryParameters } from './actions/records/rebalanceRecordInventory.js' +export { reconcileRecordInventory, type ReconcileRecordInventoryParameters } from './actions/records/reconcileRecordInventory.js' +export { joinRecords, type JoinRecordsParameters } from './actions/records/joinRecords.js' +export { splitRecord, type SplitRecordParameters } from './actions/records/splitRecord.js' +export { autoJoin, type AutoJoinParameters } from './actions/records/autoJoin.js' diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts new file mode 100644 index 00000000..fc834468 --- /dev/null +++ b/packages/core/src/inventory/internal.ts @@ -0,0 +1,108 @@ +import type { Client } from '../clients/createClient.js' +import type { OwnedRecordEncrypted, OwnedRecord } from '../types/records.js' +import { parseRecord } from '../utils/records.js' +import type { RecordActionsConfig, RecordManagementConfig, RecordInventoryStore } from './types.js' +import { heldRecordIds, memoryRecordInventoryStore } from './store.js' +import { validateTokenJoinRouter } from './tokenJoin.js' +import type { Transport } from '../types/transport.js' + +/** + * Holds the enabled client's journal, limits, and chain settings. + * @property enabled Records that the caller enabled reservations and recovery. + * @property chainId Stable chain identity for reservation scopes. + * @property store Shared atomic journal. + * @property maxFeeMicrocredits Optional proved fee ceiling; absent means no limit. + * @property tokenJoin Optional dynamic ARC router override; omission selects available helpers and false disables helpers. + * @property joinAsset Internal source-asset attribution for a validated helper call. + * @property network Original transport network, checked against later network switches. + * @internal + */ +export type RecordManagement = Required> & { + enabled: true + store: RecordInventoryStore + maxFeeMicrocredits?: bigint + tokenJoin?: RecordActionsConfig['tokenJoin'] + joinAsset?: { program: string; assetProgram: string } + network: string | null | undefined +} + +/** + * Resolves record settings before wallet actions capture the client. + * Disabled configuration creates no store and skips validation of write restrictions. + * @internal + */ +export function configureRecordManagement(config: RecordManagementConfig | undefined, transport: Transport): RecordManagementConfig | undefined { + if (!config) return undefined + // Copy caller-owned options so later mutation cannot switch a live client's scope. + const tokenJoin = config.tokenJoin && { ...config.tokenJoin } + if (config.enabled !== true) return { ...config, tokenJoin } + const chainId = config.chainId ?? transport.config.network + if (!chainId) throw new Error('recordManagement requires chainId on a transport without a network') + if (config.maxFeeMicrocredits !== undefined && config.maxFeeMicrocredits < 0n) throw new Error('Fee limit cannot be negative') + if (tokenJoin) validateTokenJoinRouter(tokenJoin) + const resolved: RecordManagement = { + ...config, enabled: true, chainId, tokenJoin, + store: config.store ?? memoryRecordInventoryStore(), network: transport.config.network, + } + return resolved +} + +/** Reads only explicitly enabled coordination and rejects a changed transport network. @internal */ +export function management(client: Client): RecordManagement | undefined { + const config = client.recordManagement as RecordManagement | undefined + if (config?.enabled !== true) return undefined + if (config.network !== client.transport.config.network) { + throw new Error('Record management network changed; rebuild the client for the new chain') + } + return config +} + +/** Reads routing settings independently of whether spending coordination is enabled. @internal */ +export function recordSettings(client: Client): Pick { + // Client settings take precedence over recordActions options so a decorator + // cannot change the chain used to look up reservations. + const settings = client.recordManagement ?? (client as Client & { recordActionConfig?: RecordActionsConfig }).recordActionConfig ?? {} + if (settings.tokenJoin) validateTokenJoinRouter(settings.tokenJoin) + return settings +} + +/** Derives the reservation namespace from the active chain and account address. @internal */ +export function scopeOf(client: Client): string { + const chain = management(client)?.chainId ?? recordSettings(client).chainId ?? client.transport.config.network + if (!chain || !client.account?.address) throw new Error('Inventory requires an account address and chain identity') + return JSON.stringify([chain, client.account.address]) +} + +/** + * Returns a record nonce shared by local scanning and wallet field grants. + * @param record Owned record exposing plaintext or the granted $nonce metadata field. + * @returns Stable nonce for reservation, independent of connection-specific wallet uids. + * @throws When the record's identity was not granted. + * @example + * const id = inventoryRecordId(record) + */ +export function inventoryRecordId(record: OwnedRecordEncrypted): string { + const plaintext = (record as Partial).recordPlaintext + const nonce = plaintext ? parseRecord(plaintext).nonce : record.recordView?.fields['$nonce']?.replace(/\.public$/, '') + if (!nonce || !/^\d+group$/.test(nonce)) throw new Error('Inventory requires record plaintext or a $nonce grant') + return nonce +} + +/** + * Lists nonces held by pending or confirmed operations, including stale scanner inputs. + * @param client Client with enabled recordManagement; returns an empty set when disabled or omitted. + * @returns Nonces excluded from selection. Reads the configured journal. + * @example + * const excluded = await reservedRecordIds(client) + */ +export async function reservedRecordIds(client: Client): Promise> { + const config = management(client) + if (!config) return new Set() + // Confirmed inputs remain excluded while a scanner still returns stale records. + return new Set((await config.store.list(scopeOf(client))).flatMap(heldRecordIds)) +} + +/** Enforces positive bounded integers; the default upper bound is 1000. @internal */ +export function positiveInteger(value: number, name: string, max = 1000): void { + if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} must be an integer from 1 to ${max}`) +} diff --git a/packages/core/src/inventory/joinPrograms.ts b/packages/core/src/inventory/joinPrograms.ts new file mode 100644 index 00000000..a3effede --- /dev/null +++ b/packages/core/src/inventory/joinPrograms.ts @@ -0,0 +1,134 @@ +import type { Client } from '../clients/createClient.js' +import type { ProgramFunction } from '../types/program.js' +import type { RecordJoinProgram, ResolvedRecordAsset } from './types.js' +import { getCode } from '../actions/public/getCode.js' +import { parseProgram } from '../contract/parseProgram.js' +import { recordSettings } from './internal.js' + +const counts = (min: number, max: number) => Array.from({ length: max - min + 1 }, (_, index) => min + index) +// Static helper deployments divide their join functions into three size ranges. +const wrappers = (prefix: string): readonly RecordJoinProgram[] => [[2, 10], [11, 14], [15, 16]].map(([min, max]) => ({ + program: `${prefix}_${min}_${max}.aleo`, inputMode: 'records', counts: counts(min!, max!), +})) +const arc = (network: 'mainnet' | 'testnet'): readonly RecordJoinProgram[] => [{ + program: `${network === 'mainnet' ? 'main' : 'test'}_aj_arc20_2_15.aleo`, inputMode: 'programIdentifierThenRecords', counts: counts(2, 15), +}] + +/** + * Lists known join helpers by network and source program, with ARC defaults. + * Exact program entries take precedence over the arc20/arc22 entries. Selection + * verifies each helper's deployed ABI through the client's transport; the list + * alone does not establish that a helper is available on a particular node. + * @example + * const candidates = recordJoinPrograms.testnet['credits.aleo'] + */ +export const recordJoinPrograms: Readonly>>> = { + mainnet: { + 'credits.aleo': wrappers('autojoin_credits'), + 'usad_stablecoin.aleo': wrappers('aj_usad_stablecoin'), + 'usdcx_stablecoin.aleo': wrappers('aj_usdcx_stablecoin'), + arc20: arc('mainnet'), arc22: arc('mainnet'), + }, + testnet: { + 'credits.aleo': wrappers('autojoin_credits'), + 'test_usad_stablecoin.aleo': wrappers('aj_test_usad_stablecoin'), + 'test_usdcx_stablecoin.aleo': wrappers('aj_test_usdcx_stablecoin'), + arc20: arc('testnet'), arc22: arc('testnet'), + }, +} + +/** + * Validates helper metadata without contacting a node. + * @param helper Program, input convention, and supported record counts. + * @returns Nothing when all counts fit the helper's input convention. + * @throws On invalid program ids, duplicate counts, or counts outside 2 to 16 (15 for dynamic helpers). + * @example + * validateRecordJoinProgram({ program: 'autojoin_credits_2_10.aleo', inputMode: 'records', counts: [3] }) + * @internal + */ +export function validateRecordJoinProgram(helper: RecordJoinProgram): void { + if (!helper || !/^[a-z][a-z0-9_]*\.aleo$/.test(helper.program) || + !['records', 'programIdentifierThenRecords'].includes(helper.inputMode) || + !Array.isArray(helper.counts) || !helper.counts.length || new Set(helper.counts).size !== helper.counts.length || + helper.counts.some(count => !Number.isInteger(count) || count < 2 || count > (helper.inputMode === 'records' ? 16 : 15))) { + throw new Error('Invalid record join helper configuration') + } +} + +/** Checks that a helper accepts the selected record type and returns one record. @internal */ +function compatible(fn: ProgramFunction | undefined, asset: ResolvedRecordAsset, helper: RecordJoinProgram, count: number): boolean { + if (!fn || fn.outputs.length !== 1) return false + if (helper.inputMode === 'programIdentifierThenRecords') { + // Dynamic ARC helpers call Token.join and sum amount. Field overrides must + // not send a different record or balance through that interface. + const first = fn.inputs[0] + return asset.recordName === 'Token' && asset.amountField === 'amount' && asset.amountType === 'u128' && + fn.inputs.length === count + 1 && first?.kind === 'plaintext' && first.type === 'identifier' && first.visibility === 'public' && + fn.inputs.slice(1).every(input => input.kind === 'dynamicRecord') && fn.outputs[0]?.kind === 'dynamicRecord' + } + const externalRecord = `${asset.program}/${asset.recordName}` + return fn.inputs.length === count && fn.inputs.every(input => input.kind === 'record' && input.type === externalRecord) && + fn.outputs[0]?.kind === 'record' && fn.outputs[0].type === externalRecord +} + +/** + * Finds available helpers for an asset without signing or reserving records. + * @param client Client whose transport supplies the network and deployed program source. + * @param asset Resolved record and amount field to match against helper inputs. + * @returns Compatible deployed helpers; empty when disabled, missing, or unsupported on the network. + * @throws On transport failures other than a missing deployment. + * @example + * const helpers = await getRecordJoinPrograms(client, inventory.asset) + * @internal + */ +export async function getRecordJoinPrograms(client: Client, asset: ResolvedRecordAsset): Promise { + const configured = recordSettings(client).tokenJoin + // Explicit dynamic routing is handled by the existing tokenJoin option. + if (configured === false || (configured && asset.program !== 'credits.aleo')) return [] + const network = client.transport.config.network + if (network !== 'mainnet' && network !== 'testnet') return [] + const defaultsMatch = asset.program === 'credits.aleo' + ? asset.recordName === 'credits' && asset.amountField === 'microcredits' + : asset.recordName === 'Token' && asset.amountField === 'amount' && asset.amountType === 'u128' + if (!defaultsMatch) return [] + const registry = network === 'mainnet' ? recordJoinPrograms.mainnet : recordJoinPrograms.testnet + const candidates = registry[asset.program] ?? (asset.standard ? registry[asset.standard] : undefined) ?? [] + const available: RecordJoinProgram[] = [] + for (const helper of candidates) { + let source: string + try { source = await getCode(client, { programId: helper.program }) } + catch (error) { + // Missing deployments permit native joins. Transport outages must still + // surface instead of silently changing the planned transaction count. + if ((error as { status?: number }).status === 404) continue + throw error + } + if (typeof source !== 'string') continue + const program = parseProgram(source) + if (program.id !== helper.program) continue + const supported = helper.counts.filter(count => compatible(program.functions.find(fn => fn.name === `join_${count}`), asset, helper, count)) + if (supported.length) available.push({ ...helper, counts: supported }) + } + return available +} + +/** + * Rechecks the captured helper ABI immediately before submitting its inputs. + * @param client Client connected to the helper's chain. + * @param asset Resolved asset whose records will be joined. + * @param helper Selected deployment and supported input counts. + * @param count Number of consumed records, bounded by the helper's supported counts. + * @returns Resolves when the deployed function accepts and returns the selected record type. + * @throws On an unsupported count, incompatible signature, or failed program lookup. + * @example + * await validateRecordJoinFunction(client, inventory.asset, helper, 3) + * @internal + */ +export async function validateRecordJoinFunction(client: Client, asset: ResolvedRecordAsset, helper: RecordJoinProgram, count: number): Promise { + validateRecordJoinProgram(helper) + if (!helper.counts.includes(count)) throw new Error('Record count is outside the join helper limits') + const program = parseProgram(await getCode(client, { programId: helper.program })) + if (program.id !== helper.program || !compatible(program.functions.find(fn => fn.name === `join_${count}`), asset, helper, count)) { + throw new Error('Record join helper has an incompatible function signature') + } +} diff --git a/packages/core/src/inventory/planner.ts b/packages/core/src/inventory/planner.ts new file mode 100644 index 00000000..577f1368 --- /dev/null +++ b/packages/core/src/inventory/planner.ts @@ -0,0 +1,140 @@ +import type { InventoryPlan, InventoryStep, InventoryTarget, RecordAsset, TokenJoinRouter, RecordJoinProgram } from './types.js' +import { positiveInteger } from './internal.js' +import { maxAmount, splitDeduction } from '../actions/records/internal.js' +import { validateTokenJoinRouter } from './tokenJoin.js' +import { validateRecordJoinProgram } from './joinPrograms.js' + +/** + * Computes a deterministic join/split dependency plan without network calls. + * @param params Scoped record amounts, target count/distribution and optional operation ceiling (default 100). + * @returns A plaintext-free plan with intrinsic credits split deductions. + * @throws On invalid bounds, infeasible targets, intermediate overflow or too many operations. + * @internal + */ +export function buildInventoryPlan(params: { + scope: string; asset: RecordAsset; target: InventoryTarget + inputs: { id: string; amount: bigint }[]; maxTransactions?: number; tokenJoin?: TokenJoinRouter | false; joinPrograms?: RecordJoinProgram[] +}): InventoryPlan { + // Capture the requested policy and validate bounds before simulating any steps. + const { asset } = params + if (params.tokenJoin) validateTokenJoinRouter(params.tokenJoin) + const tokenJoin = asset.program !== 'credits.aleo' && params.tokenJoin ? { ...params.tokenJoin } : undefined + const joinPrograms = params.joinPrograms?.map(helper => { + validateRecordJoinProgram(helper) + return { ...helper, counts: [...helper.counts] } + }) + const routing = { ...(tokenJoin ? { tokenJoin } : {}), ...(joinPrograms?.length ? { joinPrograms } : {}) } + const maxJoinRecords = tokenJoin ? validateTokenJoinRouter(tokenJoin) : 2 + const target = { ...params.target } + positiveInteger(target.records, 'records') + const maxTransactions = params.maxTransactions ?? 100 + positiveInteger(maxTransactions, 'maxTransactions', 10_000) + const min = target.minRecordAmount ?? 1n + const tolerance = target.toleranceBps ?? 1000 + if (typeof min !== 'bigint' || min < 1n || min > maxAmount(asset)) throw new Error('Invalid minimum record amount') + if (!Number.isInteger(tolerance) || tolerance < 0 || tolerance > 10_000) throw new Error('Invalid balance tolerance') + if (target.distribution && !['preserve', 'balanced'].includes(target.distribution)) throw new Error('Invalid inventory distribution') + const inputs = params.inputs.map((record) => ({ ...record })) + if (new Set(inputs.map((record) => record.id)).size !== inputs.length) throw new Error('Duplicate inventory records') + if (inputs.some((record) => record.amount <= 0n || record.amount > maxAmount(asset))) throw new Error('Invalid record amount') + let working = inputs.map((record) => ({ ...record })) + const steps: InventoryStep[] = [] + const total = working.reduce((sum, record) => sum + record.amount, 0n) + // Compare against the ideal equal share using integer arithmetic so fractional + // base units cannot introduce rounding drift into the tolerance check. + const balanced = () => working.every((record) => { + const difference = record.amount * BigInt(target.records) - total + const absolute = difference < 0n ? -difference : difference + // One base unit of rounding never warrants another transaction. + return absolute <= BigInt(target.records) || absolute * 10_000n <= total * BigInt(tolerance) + }) + const satisfied = working.length === target.records && working.every((record) => record.amount >= min) && + (target.distribution !== 'balanced' || balanced()) + // Replace consumed records with symbolic outputs that later steps can reference. + // These ids describe dependencies; execution resolves them to confirmed records. + const push = (kind: 'join' | 'split', consumed: typeof working, amounts: bigint[], amount?: bigint) => { + if (steps.length >= maxTransactions) throw new Error('Inventory plan exceeds maxTransactions') + if (amounts.some((value) => value < min || value > maxAmount(asset))) throw new Error('Inventory target is infeasible within token amount bounds') + const outputs = amounts.map((value, index) => ({ id: `step:${steps.length}:${index}`, amount: value })) + steps.push({ kind, inputs: consumed.map((record) => record.id), outputs, amount, deduction: kind === 'split' ? splitDeduction(asset) : 0n }) + const ids = new Set(consumed.map((record) => record.id)) + working = working.filter((record) => !ids.has(record.id)).concat(outputs) + } + // Preserve exact lots when larger records can be split directly into the + // missing lots. This avoids a consolidate-and-split round trip for [25,25,50]. + if (!satisfied && target.distribution === 'balanced' && working.length < target.records) { + const deduction = splitDeduction(asset) + // Each split adds one record and may deduct credits; compute the final lot size + // after all required deductions before deciding whether existing lots can stay. + const finalTotal = total - deduction * BigInt(target.records - working.length) + const unit = finalTotal / BigInt(target.records) + if (unit >= min && finalTotal % BigInt(target.records) === 0n && + working.every((record) => (record.amount + deduction) % (unit + deduction) === 0n)) { + const original = [...working] + for (const originalRecord of original) { + let record = originalRecord + while (record.amount > unit) { + push('split', [record], [unit, record.amount - unit - deduction], unit) + record = steps.at(-1)!.outputs[1]! + } + } + return { scope: params.scope, asset: { ...asset }, target, inputs, steps, ...routing, + deduction: steps.reduce((sum, step) => sum + step.deduction, 0n), maxTransactions } + } + } + if (!satisfied) { + if (!working.length || total < BigInt(target.records) * min) throw new Error('Insufficient available inventory for target') + // Repack only when existing records cannot be preserved: balanced layouts or undersized records. + const capacity = working.reduce((sum, record) => sum + (record.amount + splitDeduction(asset)) / (min + splitDeduction(asset)), 0n) + const repack = target.distribution === 'balanced' || working.some((record) => record.amount < min) || capacity < BigInt(target.records) + const reduceTo = repack ? 1 : target.records + // Join the smallest records first, with record ids breaking equal-amount + // ties so rebuilding the same plan produces the same dependency sequence. + while (working.length > reduceTo) { + working.sort((a, b) => a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : a.id.localeCompare(b.id)) + const needed = working.length - reduceTo + 1 + // Prefer the largest available helper that preserves the requested count. + // A final pair uses the native join unless tokenJoin was explicitly set. + const helperCount = Math.max(2, ...(joinPrograms ?? []).flatMap(helper => helper.counts.filter(count => count <= needed))) + let count = Math.min(tokenJoin ? maxJoinRecords : helperCount, needed) + let amount = working.slice(0, count).reduce((sum, record) => sum + record.amount, 0n) + // Reduce the batch if its amount would overflow or its exact arity is unavailable. + while (count > 2 && (amount > maxAmount(asset) || (!tokenJoin && !joinPrograms?.some(helper => helper.counts.includes(count))))) { + amount -= working[--count]!.amount + } + const helper = !tokenJoin && count > 2 ? joinPrograms?.find(helper => helper.counts.includes(count)) : undefined + const consumed = working.slice(0, count) + // Intermediate joins can be below the output minimum while accumulating dust. + if (amount > maxAmount(asset)) throw new Error('Join would overflow the asset amount width') + const output = { id: `step:${steps.length}:0`, amount } + if (steps.length >= maxTransactions) throw new Error('Inventory plan exceeds maxTransactions') + steps.push({ kind: 'join', ...(helper ? { joinProgram: { ...helper, counts: [...helper.counts] } } : {}), inputs: consumed.map((record) => record.id), outputs: [output], deduction: 0n }) + working = working.slice(count).concat(output) + } + // Split the largest remaining record and leave enough value in its remainder + // for any dependent splits, including their intrinsic credits deductions. + while (working.length < target.records) { + working.sort((a, b) => a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : a.id.localeCompare(b.id)) + const record = working[0]! + const deduction = splitDeduction(asset) + let outputsNeeded = BigInt(target.records - working.length + 1) + if (!repack) { + // Keep halving when its outputs can still reach the target. Otherwise, + // leave enough in the remainder for later splits: 9 needs 3 + 6, not 4 + 5, + // to produce three records of at least 3. + const capacity = (record.amount + deduction) / (min + deduction) + const half = (record.amount - deduction) / 2n + const splitCapacity = (half + deduction) / (min + deduction) + (record.amount - half) / (min + deduction) + const otherCapacity = working.slice(1).reduce((sum, item) => sum + (item.amount + deduction) / (min + deduction), 0n) + if (otherCapacity + splitCapacity >= BigInt(target.records)) outputsNeeded = 2n + else if (capacity < outputsNeeded) outputsNeeded = capacity + } + const usable = record.amount - deduction * (outputsNeeded - 1n) + const amount = usable / outputsNeeded + push('split', [record], [amount, record.amount - amount - deduction], amount) + } + if (working.some((record) => record.amount < min)) throw new Error('Insufficient balance after split deductions') + } + return { scope: params.scope, asset: { ...asset }, target, inputs, steps, ...routing, + deduction: steps.reduce((sum, step) => sum + step.deduction, 0n), maxTransactions } +} diff --git a/packages/core/src/inventory/recordActions.ts b/packages/core/src/inventory/recordActions.ts new file mode 100644 index 00000000..9297e031 --- /dev/null +++ b/packages/core/src/inventory/recordActions.ts @@ -0,0 +1,52 @@ +import type { Client } from '../clients/createClient.js' +import type { RecordActionsConfig } from './types.js' +import { getRecordInventory, type GetRecordInventoryParameters } from '../actions/records/getRecordInventory.js' +import { planRecordInventory, type PlanRecordInventoryParameters } from '../actions/records/planRecordInventory.js' +import { rebalanceRecordInventory, type RebalanceRecordInventoryParameters } from '../actions/records/rebalanceRecordInventory.js' +import { reconcileRecordInventory, type ReconcileRecordInventoryParameters } from '../actions/records/reconcileRecordInventory.js' +import { joinRecords, type JoinRecordsParameters } from '../actions/records/joinRecords.js' +import { splitRecord, type SplitRecordParameters } from '../actions/records/splitRecord.js' +import { autoJoin, type AutoJoinParameters } from '../actions/records/autoJoin.js' +import { validateTokenJoinRouter } from './tokenJoin.js' + +/** + * Binds record methods to an account client without changing its spending settings. + * Used by walletActions and recordActions. + * @internal + */ +export function recordActionMethods(client: Client) { + return { + getRecordInventory: (params: GetRecordInventoryParameters) => getRecordInventory(client, params), + planRecordInventory: (params: PlanRecordInventoryParameters) => planRecordInventory(client, params), + rebalanceRecordInventory: (params: RebalanceRecordInventoryParameters) => rebalanceRecordInventory(client, params), + reconcileRecordInventory: (params?: ReconcileRecordInventoryParameters) => reconcileRecordInventory(client, params), + joinRecords: (params: JoinRecordsParameters) => joinRecords(client, params), + splitRecord: (params: SplitRecordParameters) => splitRecord(client, params), + autoJoin: (params: AutoJoinParameters) => autoJoin(client, params), + } +} + +/** + * Adds inventory methods to a custom client. + * Wallet clients already include these methods. This decorator leaves reservations + * and recovery disabled unless the client was created with recordManagement + * enabled: true. Its store and maxFeeMicrocredits options are ignored; supply + * them in the client's recordManagement configuration to reserve records during + * writes and execute inventory plans. + * @param config Optional chain identity and token join router. Defaults to the + * transport network and native joins. The client's recordManagement settings + * take precedence when present. + * @returns A decorator for client.extend(). Creates no journal and makes no network requests. + * @throws When the optional token join router is invalid. + * @example + * const client = walletClient.extend(recordActions({ tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) + */ +export function recordActions(config: RecordActionsConfig = {}) { + // Keep chain and router settings here; the client configuration controls reservations. + const recordActionConfig = { chainId: config.chainId, tokenJoin: config.tokenJoin && { ...config.tokenJoin } } + if (recordActionConfig.tokenJoin) validateTokenJoinRouter(recordActionConfig.tokenJoin) + return (client: Client) => { + const scoped = Object.assign(Object.create(client), { recordActionConfig }) as Client + return { recordActionConfig, ...recordActionMethods(scoped) } + } +} diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts new file mode 100644 index 00000000..5985869f --- /dev/null +++ b/packages/core/src/inventory/spending.ts @@ -0,0 +1,160 @@ +import { RecordReservationConflictError } from '../errors/errors.js' +import type { Client } from '../clients/createClient.js' +import type { TransactionInput } from '../types/inputRequest.js' +import type { ProvingProgressHandler } from '../types/proving.js' +import type { InventoryRecord, RecordSpend } from './types.js' +import type { OwnedRecord } from '../types/records.js' +import { getRecordInventory } from '../actions/records/getRecordInventory.js' +import type { Transaction } from '../types/transaction.js' +import { requestRecords } from '../actions/wallet/requestRecords.js' +import { parseRecord } from '../utils/records.js' +import { inventoryRecordId, management, reservedRecordIds, scopeOf } from './internal.js' + +/** Builds an internal client view that avoids recursive reservation acquisition. @internal */ +export function withoutManagement(client: Client, feeRecord?: string): Client { + // Preserve the original account, transport, and prover without mutating the + // caller's client; only the nested action skips reservation acquisition. + const proving = feeRecord && client.proving ? { ...client.proving, + buildTransaction: client.proving.buildTransaction && ((options) => client.proving!.buildTransaction!({ ...options, feeRecord })), + execute: client.proving.execute && ((options) => client.proving!.execute!({ ...options, feeRecord })), + } satisfies NonNullable : client.proving + return Object.assign(Object.create(client), { recordManagement: undefined, proving }) as Client +} + +/** Reads base and priority fees in microcredits for public or private payment; rejects an unverifiable fee. @internal */ +export function transactionFee(tx: Transaction): bigint { + if (!tx.fee) return 0n + // Both fee functions expose the amounts publicly. fee_private puts its + // consumed credits record before those amounts. + const offset = tx.fee.transition.function === 'fee_private' ? 1 : 0 + const values = tx.fee.transition.inputs?.slice(offset, offset + 2).map((input) => input.value) + if (values?.length !== 2 || values.some((value) => !/^\d+u64$/.test(value ?? ''))) { + throw new Error('Cannot verify transaction fee before broadcast') + } + return values.reduce((sum, value) => sum + BigInt(value!.slice(0, -3)), 0n) +} + +/** Reserves explicit inputs and persists each awaited proving boundary before allowing submission. @internal */ +export async function manageRecordSpend(client: Client, params: { + program: string; function: string; inputs: TransactionInput[]; privateFee?: boolean; feeRecord?: string; fee?: bigint; programSource?: string + imports?: string[] | Record + onProgress?: ProvingProgressHandler +}, submit: (onProgress: ProvingProgressHandler, feeRecord?: string) => Promise): Promise { + const config = management(client)! + if (params.privateFee && client.account?.type !== 'local') { + throw new Error('Managed private fees require a local signer; this wallet protocol cannot pin a fee record') + } + const scope = scopeOf(client) + if (client.account?.type === 'rpc' && config.maxFeeMicrocredits !== undefined) throw new Error('Wallet accounts cannot enforce a fee ceiling before submission') + // Normalize wallet-selected and plaintext inputs to stable record nonces so + // clients using different scanner or wallet representations share reservations. + const ids: string[] = [] + for (const input of params.inputs) { + if (typeof input === 'object' && input.type === 'record') { + if (!input.uid) throw new Error('Managed wallet spending requires a pinned record uid') + // Resolve the pinned uid again rather than letting the wallet select a + // different record after the reservation has been acquired. + const records = await requestRecords(client, { program: input.program, statusFilter: 'unspent' }) + const record = records.find((record) => record.uid === input.uid) + if (!record) throw new Error('Pinned wallet record is no longer available') + ids.push(inventoryRecordId(record)) + } else if (typeof input === 'string' && input.trim().startsWith('{') && input.includes('_nonce')) { + const record = parseRecord(input) + if (record.owner !== client.account?.address) throw new Error('Cannot reserve another account’s record') + ids.push(record.nonce) + } else if (typeof input === 'string' && input.startsWith('record1')) { + throw new Error('Managed spending requires a record nonce; ciphertext alone is insufficient') + } + } + if (new Set(ids).size !== ids.length) throw new Error('A transaction cannot spend the same record twice') + // Attribute routed joins to the underlying token for per-asset maintenance + // cooldowns; the journal still records the actual router call separately. + const assetProgram = config.joinAsset?.program === params.program ? config.joinAsset.assetProgram : config.tokenJoin && config.tokenJoin.program === params.program && /^join_\d+$/.test(params.function) && Array.isArray(params.imports) + ? params.imports[0] : undefined + let feeRecord: InventoryRecord | undefined + let entry: RecordSpend | undefined + // Include the fee record in the same atomic acquisition as the execution + // inputs. Try another eligible fee record if a competing writer reserved it. + const acquire = (fee?: InventoryRecord) => config.store.acquire({ scope, records: fee ? [...ids, fee.id] : ids, + ...(fee ? { feeRecordId: fee.id } : {}), program: params.program, ...(assetProgram ? { assetProgram } : {}), + function: params.function, status: 'reserved', createdAt: Date.now(), accountType: client.account?.type === 'local' ? 'local' : 'rpc' }) + if (params.privateFee) { + const estimate = client.proving?.estimateExecutionFee + if (!estimate) throw new Error('Managed private fees require a prover with estimateExecutionFee and pinned feeRecord support') + const base = await estimate({ programName: params.program, functionName: params.function, programSource: params.programSource, imports: params.imports }) + const priority = params.fee ?? 0n + if (typeof base !== 'bigint' || base < 0n || priority < 0n) throw new Error('Invalid private fee estimate or priority fee') + const required = base + priority + if (config.maxFeeMicrocredits !== undefined && required > config.maxFeeMicrocredits) throw new Error('Estimated transaction exceeds maxFeeMicrocredits') + const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo' } }) + // A supplied record is a preference, never permission to reuse a reserved + // input. Resolve it against the current scan and use the scanner plaintext. + const preferred = params.feeRecord ? parseRecord(params.feeRecord).nonce : undefined + const candidates = inventory.available.filter(record => !ids.includes(record.id) && record.amount >= required && + !!(record.record as Partial).recordPlaintext && /^\d+field$/.test(record.record.tag ?? '')) + .sort((a, b) => a.id === preferred ? -1 : b.id === preferred ? 1 : a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : a.id.localeCompare(b.id)) + if (scopeOf(client) !== scope) throw new Error('Account or chain changed during private fee selection') + for (const candidate of candidates) { + entry = await acquire(candidate) + if (entry) { feeRecord = candidate; break } + // A fixed execution input can also lose this atomic acquisition. Report + // that separately so callers never treat exhausted fee records as a reason + // to rebuild an execution with another token record. + const held = await reservedRecordIds(client) + const conflicting = ids.filter(id => held.has(id)) + if (conflicting.length) throw new RecordReservationConflictError(conflicting) + } + if (!entry) throw new Error('No available credits record covers the private fee separately from the transaction inputs; refresh records or fund private credits') + } else { + entry = await acquire() + } + if (!entry) throw new RecordReservationConflictError(ids) + let prepared = false + let submitted = false + try { + return await submit(async (event) => { + // A changed account/network invalidates the reservation namespace. + if (scopeOf(client) !== scope) throw new Error('Account or chain changed during record spending') + if (event.type === 'transaction-prepared') { + // Enforce the proved fee and save the exact transaction before allowing + // broadcast, so recovery can resend it without constructing a second spend. + // Verify the prover honored the pinned record before saving a transaction + // that recovery could broadcast. Tags identify the consumed scanner record. + const feeTransition = event.transaction.fee?.transition + if (feeRecord && (feeTransition?.program !== 'credits.aleo' || feeTransition.function !== 'fee_private' || + feeTransition.inputs?.[0]?.type !== 'record' || feeTransition.inputs[0].tag !== feeRecord.record.tag)) { + throw new Error('Prover did not spend the reserved private fee record') + } + const fee = transactionFee(event.transaction) + if (feeRecord && fee > feeRecord.amount) throw new Error('Private fee exceeds the reserved credits record balance') + if (config.maxFeeMicrocredits !== undefined && fee > config.maxFeeMicrocredits) { + throw new Error('Proved transaction exceeds maxFeeMicrocredits') + } + await config.store.update(scope, entry.id, { status: 'prepared', transactionId: event.transactionId, + transaction: event.transaction, feeMicrocredits: fee.toString() }) + prepared = true + } else if (event.type === 'transaction-submitted') { + // Mark submission before awaiting storage; a storage failure must not + // release inputs that may already have been accepted by the network. + submitted = true + await config.store.update(scope, entry.id, { status: 'submitted', transactionId: event.transactionId }) + } else if (event.type === 'transaction-confirmed') { + await config.store.update(scope, entry.id, { status: 'confirmed', transactionId: event.transactionId, transaction: undefined }) + } + // Update the journal first, then await the caller's own checkpoint handler. + await params.onProgress?.(event) + }, feeRecord ? (feeRecord.record as OwnedRecord).recordPlaintext : undefined) + } catch (error) { + // Local build paths never broadcast before transaction-prepared. Wallets may + // have submitted even if their response was lost, so their reservations remain. + if (!prepared && !submitted && client.account?.type === 'local') { + await config.store.update(scope, entry.id, { status: 'cancelled' }) + } + // A nested action or caller checkpoint may throw its own conflict after + // this execution acquired inputs. It must not authorize retrying this spend. + if (error instanceof RecordReservationConflictError) { + throw new Error('Record spending failed after acquiring inputs', { cause: error }) + } + throw error + } +} diff --git a/packages/core/src/inventory/store.ts b/packages/core/src/inventory/store.ts new file mode 100644 index 00000000..a09f5f01 --- /dev/null +++ b/packages/core/src/inventory/store.ts @@ -0,0 +1,49 @@ +import type { RecordInventoryStore, RecordSpend } from './types.js' + +/** + * Builds an atomic, process-local reservation store. + * Data disappears when the process exits; use a durable implementation for bots. + * @returns A store shared by every client given the returned instance. + * @example + * const store = memoryRecordInventoryStore() + */ +export function memoryRecordInventoryStore(): RecordInventoryStore { + const entries: RecordSpend[] = [] + // Journal values are wire-format JSON, so cloning needs no runtime-specific API. + const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T + return { + list: async (scope) => copy(entries.filter((entry) => entry.scope === scope)), + acquire: async (entry) => { + // Check contention and insert without awaiting so another caller in this + // process cannot reserve an input between the check and the insertion. + if (new Set(entry.records).size !== entry.records.length) throw new Error('Duplicate record inputs') + if (entries.some((old) => old.scope === entry.scope && holdsRecords(old) && + heldRecordIds(old).some((id) => entry.records.includes(id)))) return undefined + const saved = copy({ ...entry, id: String(entries.length + 1) }) + entries.push(saved) + return copy(saved) + }, + update: async (scope, id, patch) => { + const entry = entries.find((item) => item.scope === scope && item.id === id) + if (!entry) throw new Error('Unknown record reservation') + Object.assign(entry, copy(patch)) + // JSON cloning omits undefined; explicitly remove fields cleared after + // a successful rebroadcast or confirmed transaction. + for (const key of ['transaction', 'lastRebroadcastError'] as const) { + if (key in patch && patch[key] === undefined) delete entry[key] + } + }, + } +} + +/** Returns whether a journal entry excludes its inputs from record selection. */ +export function holdsRecords(entry: RecordSpend): boolean { + // Confirmed inputs remain excluded while a scanner still reports them unspent. + return entry.status !== 'cancelled' && (entry.status !== 'rejected' || !!entry.feeRecordId) +} + +/** Returns the consumed fee record alone after rejection; other held states retain every input. @internal */ +export function heldRecordIds(entry: RecordSpend): string[] { + if (!holdsRecords(entry)) return [] + return entry.status === 'rejected' ? [entry.feeRecordId!] : entry.records +} diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts new file mode 100644 index 00000000..504f8213 --- /dev/null +++ b/packages/core/src/inventory/tokenJoin.ts @@ -0,0 +1,104 @@ +import type { Client } from '../clients/createClient.js' +import type { Transaction } from '../types/transaction.js' +import type { InventoryStep, RecordAsset, TokenJoinRouter } from './types.js' +import { getCode } from '../actions/public/getCode.js' +import { parseProgram } from '../contract/parseProgram.js' + +/** + * Validates a configured router without network calls. + * @param router Deployed program id and optional input ceiling, defaulting to 15. + * @returns Maximum inputs per transaction, from 2 to 15. + * @throws When the program id or input ceiling is invalid. + * @example + * const limit = validateTokenJoinRouter({ program: 'main_aj_arc20_2_15.aleo' }) + * @internal + */ +export function validateTokenJoinRouter(router: TokenJoinRouter): number { + if (!router || !/^[a-z][a-z0-9_]*\.aleo$/.test(router.program)) throw new Error('Invalid token join router program') + const max = router.maxRecords ?? 15 + if (!Number.isInteger(max) || max < 2 || max > 15) throw new Error('Token join router maxRecords must be from 2 to 15') + return max +} + +/** + * Verifies the selected deployed router signature before any proof or reservation. + * Reads the program through the client's transport. + * @param client Client connected to the router's chain. + * @param router Program and optional batch ceiling, defaulting to 15 inputs. + * @param count Number of records to join, from 2 through the configured ceiling. + * @returns Resolves when the public identifier and dynamic record signature match. + * @throws On invalid counts, incompatible signatures or transport errors. + * @example + * await validateTokenJoinFunction(client, { program: 'main_aj_arc20_2_15.aleo' }, 4) + * @internal + */ +export async function validateTokenJoinFunction(client: Client, router: TokenJoinRouter, count: number): Promise { + const max = validateTokenJoinRouter(router) + if (!Number.isInteger(count) || count < 2 || count > max) throw new Error(`Token join requires 2 to ${max} records`) + // Validate the deployed join_N entrypoint, including the public token identifier + // and dynamic record slots, before sending records to a caller-selected router. + const program = parseProgram(await getCode(client, { programId: router.program })) + const fn = program.functions.find((item) => item.name === `join_${count}`) + const identifier = fn?.inputs[0] + if (program.id !== router.program || !fn || fn.inputs.length !== count + 1 || + identifier?.kind !== 'plaintext' || identifier.type !== 'identifier' || identifier.visibility !== 'public' || + fn.inputs.slice(1).some((input) => input.kind !== 'dynamicRecord') || + fn.outputs.length !== 1 || fn.outputs[0]?.kind !== 'dynamicRecord') { + throw new Error('Token join router has an incompatible function signature') + } +} + +/** + * Resolves confirmed inventory outputs to underlying scanner commitments. + * Computes from the transaction without network calls. + * @param transaction Accepted execution including nested transitions and the outer call. + * @param asset Underlying record program from the reviewed plan. + * @param step Planned join or split whose outputs must be matched. + * @param router Optional captured dynamic router; omission uses the step helper or native transition. + * @returns Record commitments in planned output order. + * @throws When the outer call or nested output cannot be matched unambiguously. + * @example + * const commitments = inventoryOutputCommitments(transaction, plan.asset, plan.steps[0]!, plan.tokenJoin) + * @internal + */ +export function inventoryOutputCommitments(transaction: Transaction, asset: RecordAsset, step: InventoryStep, router?: TokenJoinRouter): string[] { + const transitions = transaction.execution?.transitions ?? [] + // Nested token calls precede the outer call. Check the outer program and function + // against the plan before accepting any nested output as this step's result. + const outer = transitions.at(-1) + const helper = step.kind === 'join' ? step.joinProgram : undefined + const routed = step.kind === 'join' && asset.program !== 'credits.aleo' && router + const target = helper?.program ?? (routed ? router.program : asset.program) + if (outer?.program !== target || + outer.function !== (helper || routed ? `join_${step.inputs.length}` : step.kind)) { + throw new Error('Confirmed transition does not match the inventory plan') + } + // Static helpers return an external commitment. The final nested native join + // produces the encrypted record that scanners index; earlier joins produce + // intermediate records consumed within the same transaction. + if (helper?.inputMode === 'records') { + const outputs = outer.outputs ?? [] + const finalJoin = transitions.at(-2) + const joined = finalJoin?.outputs ?? [] + if (outputs.length !== 1 || outputs[0]?.type !== 'external_record' || + finalJoin?.program !== asset.program || finalJoin.function !== 'join' || + joined.length !== 1 || (joined[0]?.type !== 'record' && joined[0]?.type !== 'record_with_dynamic_id')) { + throw new Error('Cannot resolve confirmed helper output to the underlying record commitment') + } + return [joined[0]!.id] + } + // Native joins and splits expose scanner commitments directly. + if (!helper && !routed) return (outer.outputs ?? []).filter((output) => output.type === 'record' || output.type === 'record_with_dynamic_id').map((output) => output.id) + const outputs = outer.outputs ?? [] + if (outputs.length !== 1 || outputs[0]?.type !== 'record_dynamic') throw new Error('Unexpected confirmed router output') + // The outer dynamic id is a commitment for the router's own return slot. It + // need not equal the nested output's dynamic_id. The final native join holds + // the encrypted result and the record commitment used by the scanner. + const finalJoin = transitions.at(-2) + const joined = finalJoin?.outputs ?? [] + if (finalJoin?.program !== asset.program || finalJoin.function !== 'join' || + joined.length !== 1 || joined[0]?.type !== 'record_with_dynamic_id') { + throw new Error('Cannot resolve confirmed router output to the underlying token commitment') + } + return [joined[0]!.id] +} diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts new file mode 100644 index 00000000..40307401 --- /dev/null +++ b/packages/core/src/inventory/types.ts @@ -0,0 +1,228 @@ +import type { OwnedRecordEncrypted } from '../types/records.js' +import type { Transaction } from '../types/transaction.js' + +/** + * Selects the program and fungible record balance to manage. + * @property program Deployed program id. This is the only required field. + * @property standard Optional interface hint, verified against deployed code. + * @property recordName Optional record name. Defaults to credits for credits.aleo, + * Token for ARC tokens, or the record accepted by the program's join function. + * @property amountField Optional balance field. Defaults to amount, then microcredits; + * credits.aleo uses microcredits. An override must exist in the selected record. + * @example + * const asset: RecordAsset = { program: 'credits.aleo' } + * const custom: RecordAsset = { program: 'points.aleo', recordName: 'Balance', amountField: 'points' } + */ +export type RecordAsset = { + program: string + standard?: 'credits' | 'arc20' | 'arc22' + recordName?: string + amountField?: string +} + +/** + * Captures the record fields and integer width resolved from a program. + * @property recordName Record selected for scans and wallet input requests. + * @property amountField Field used to read balances and plan outputs. + * @property amountType Unsigned integer type declared for the amount field. + */ +export type ResolvedRecordAsset = RecordAsset & { + recordName: string + amountField: string + amountType: 'u8' | 'u16' | 'u32' | 'u64' | 'u128' +} + +/** + * Configures a dynamic token join router with join_2 through join_15 functions. + * @property program Deployed router program id on the client's chain. + * @property maxRecords Maximum inputs per transaction, from 2 to 15. Defaults to 15. + * @example + * const router: TokenJoinRouter = { program: 'main_aj_arc20_2_15.aleo' } + */ +export type TokenJoinRouter = { program: string; maxRecords?: number } + +/** + * Describes a helper that joins several records in one transaction. + * @property program Helper program id on the selected network. + * @property inputMode Whether inputs are records alone or a program identifier followed by records. + * @property counts Supported input counts, from 2 through 16 (at most 15 for dynamic ARC helpers). + */ +export type RecordJoinProgram = { + program: string + inputMode: 'records' | 'programIdentifierThenRecords' + counts: readonly number[] +} + +/** + * Describes a spendable record without requiring plaintext from a wallet. + * @property id Record nonce, stable across scanners and wallet connections. + * @property amount Integer base units, bounded by the declared field width: u64 for credits, u128 for ARC tokens. + * @property record Scanner or wallet record; plaintext remains in memory only. + */ +export type InventoryRecord = { id: string; amount: bigint; record: OwnedRecordEncrypted } + +/** + * Defines the desired spendable inventory. + * @property records Positive target count, at most 1000. + * @property minRecordAmount Minimum positive output in base units. Defaults to 1n. + * @property distribution Defaults to preserve; balanced reshapes skewed inventory. + * @property toleranceBps Allowed deviation from equal amounts, 0–10000. Defaults to 1000 (10%). + */ +export type InventoryTarget = { + records: number + minRecordAmount?: bigint + distribution?: 'preserve' | 'balanced' + toleranceBps?: number +} + +/** + * Describes one join or split, with references to earlier planned outputs. + * @property kind Transition to invoke. + * @property inputs Input nonces or previous step output references; joins consume two records natively, up to 16 with static helpers, or 15 with dynamic ARC helpers. + * @property outputs Predicted output references and amounts; never spendable until confirmed. + * @property amount Split's first output amount; absent for joins. + * @property deduction Intrinsic credits deduction, separate from transaction fees. + * @property joinProgram Helper captured for this join; omission uses the native join or plan.tokenJoin. + */ +export type InventoryStep = { + kind: 'join' | 'split' + joinProgram?: RecordJoinProgram + inputs: string[] + outputs: { id: string; amount: bigint }[] + amount?: bigint + deduction: bigint +} + +/** + * Captures a read-only inventory plan bound to an account and chain. + * @property scope Chain/account reservation namespace. + * @property asset Program with its resolved record name, amount field, and integer width. + * @property target Requested record count, minimum amount, and distribution. + * @property inputs Eligible snapshot, containing no plaintext. + * @property steps Ordered dependencies with predicted base-unit amounts. + * @property deduction Total intrinsic credits deduction; network fees are additional. + * @property maxTransactions Maximum operations allowed; defaults to 100. + * @property tokenJoin Optional explicitly configured dynamic token router. + * @property joinPrograms Available helper ABIs captured when planning; omission uses native pairwise joins. + */ +export type InventoryPlan = { + scope: string + asset: RecordAsset & Partial> + target: InventoryTarget + inputs: { id: string; amount: bigint }[] + steps: InventoryStep[] + deduction: bigint + maxTransactions: number + tokenJoin?: TokenJoinRouter + joinPrograms?: RecordJoinProgram[] +} + +/** Tracks reservation and transaction recovery states. */ +export type RecordSpendStatus = 'reserved' | 'prepared' | 'submitted' | 'confirmed' | 'rejected' | 'cancelled' + +/** + * Stores an operation's durable checkpoint, without record plaintext or keys. + * @property id Store-assigned identifier, unique within the store. + * @property scope Chain/account namespace. + * @property records Reserved record nonces, including the private fee record when present. + * @property feeRecordId Optional private fee record nonce; remains spent even when execution is rejected. + * @property program Target transaction program. + * @property function Transition name. + * @property assetProgram Underlying token program for a routed join; absent for native operations. + * @property status Last persisted lifecycle boundary. + * @property createdAt Unix time in milliseconds. + * @property accountType Distinguishes local prepare-before-broadcast from opaque wallet submission. + * @property transactionId Known transaction id, retained on ambiguous submission. + * @property transaction Fully proved transaction retained before broadcast for safe resubmission. + * @property feeMicrocredits Total network fee in microcredits, as a decimal string. + * @property rebroadcastAttempts Recovery rebroadcast attempts recorded in the journal; absent before the first attempt. + * @property lastRebroadcastError Most recent failed rebroadcast message; cleared after a successful rebroadcast or confirmed outcome. + */ +export type RecordSpend = { + id: string + scope: string + records: string[] + feeRecordId?: string + program: string + function: string + assetProgram?: string + status: RecordSpendStatus + createdAt: number + accountType: 'local' | 'rpc' + transactionId?: string + transaction?: Transaction + feeMicrocredits?: string + rebroadcastAttempts?: number + lastRebroadcastError?: string +} + +/** + * Coordinates record spending across participating clients. + * Implementations MUST atomically acquire all inputs or none, and durably finish + * each mutation before resolving. Confirmed inputs stay excluded from stale scans. + * Rejected executions release their inputs except feeRecordId, whose fee was charged. + * @property list Reads the journal for one chain/account namespace. + * @property acquire Allocates an id and reserves inputs, or returns undefined on contention. + * @property update Persists a checkpoint and optional rebroadcast diagnostics for an existing operation. + * An explicit undefined clears the transaction or lastRebroadcastError field. + * @example + * const store: RecordInventoryStore = memoryRecordInventoryStore() + */ +export type RecordInventoryStore = { + list(scope: string): Promise + acquire(entry: Omit): Promise + update(scope: string, id: string, patch: Partial>): Promise +} + +/** + * Configures record action routing without enabling spending coordination. + * @property chainId Optional chain identity for plans. Defaults to the transport network. + * @property tokenJoin Optional dynamic ARC router override. Omission selects available helpers by asset and network; false disables all helpers. + * @property store Accepted but ignored by recordActions. Set recordManagement.store + * with enabled: true on the client to share a journal. + * @property maxFeeMicrocredits Accepted but ignored by recordActions. Set + * recordManagement.maxFeeMicrocredits with enabled: true on the client to limit fees. + */ +export type RecordActionsConfig = { + store?: RecordInventoryStore + chainId?: string + maxFeeMicrocredits?: bigint + tokenJoin?: TokenJoinRouter | false +} + +/** + * Configures shared record reservations and transaction recovery on a client. + * Set enabled to true to prevent participating clients from spending the same + * record and to track unfinished transactions. Disabled settings can still + * supply a chain identity and token router for calls to record actions. + * Adding record methods alone creates no journal and leaves writes unchanged. + * @property enabled Enables reservations and recovery. Omitting recordManagement + * from the client configuration leaves both disabled. + * @property store Journal shared by participating clients. Defaults to one + * in-memory store per enabled client; unused when disabled. + * @property chainId Stable chain identity shared by participants. Defaults to the transport network; + * custom/local chains MUST supply a distinct identity. + * @property maxFeeMicrocredits Maximum fee in microcredits (u64), checked on the + * proved transaction before broadcast. Defaults to no limit; ignored when disabled. + * @property tokenJoin Optional dynamic ARC router override. Omission selects available helpers by asset and network; false uses native pairwise joins for all assets. + * @example + * const recordManagement: RecordManagementConfig = { + * enabled: true, store: memoryRecordInventoryStore(), chainId: 'aleo:testnet', + * } + */ +export type RecordManagementConfig = RecordActionsConfig & { enabled: boolean } + +/** + * Reports completed operations or a resumable interruption. + * @property status Whether every planned step finished. + * @property transactionIds Known prepared/submitted transaction ids, including uncertain submissions. + * @property completedSteps Operations confirmed with their exact outputs visible in the scanner. + * @property reason Interruption detail; absent on completion. + */ +export type InventoryResult = { + status: 'complete' | 'interrupted' + transactionIds: string[] + completedSteps: number + reason?: string +} diff --git a/packages/core/src/types/proving.ts b/packages/core/src/types/proving.ts index 0a7fe4de..6ed31655 100644 --- a/packages/core/src/types/proving.ts +++ b/packages/core/src/types/proving.ts @@ -37,13 +37,14 @@ export type ProvingProgressHandler = (event: ProvingProgressEvent) => void | Pro * source. * * When `privateFee` is true, the proving implementation resolves the fee - * record via the wallet client's `recordProvider` — callers don't pick - * the record themselves. + * record unless record management supplies a reserved `feeRecord`. * * @property {string} programName - Program id, e.g. `token.aleo`. * @property {string} functionName - Function/transition name to invoke within the program. * @property {string[]} inputs - Function inputs as Aleo-encoded strings (e.g. `'100u64'`, `'aleo1...'`). - * @property {boolean} [privateFee] - If true, pay the fee from a private record instead of the public credits balance. Defaults to `false`. The fee record is resolved via the record provider; callers do not supply one. + * @property {boolean} [privateFee] - If true, pay the fee from a private record instead of the public credits balance. Defaults to `false`. Uses feeRecord when supplied; otherwise the adapter selects the record. + * @property feeRecord Optional credits record plaintext pinned by record management. Absent by default; + * adapters MUST use this exact record when supplied with privateFee. * @property {string[]} [imports] - Names of programs reached via dynamic dispatch that the prover can't discover statically. * @property onProgress Optional awaited callback for proving lifecycle boundaries. Defaults to no reporting. */ @@ -52,6 +53,7 @@ export type BuildTransactionOptions = { functionName: string inputs: string[] privateFee?: boolean | undefined + feeRecord?: string | undefined imports?: string[] | undefined onProgress?: ProvingProgressHandler | undefined } @@ -80,16 +82,23 @@ export type SimulateOptions = { * @property inputs Function inputs as Aleo-encoded strings (e.g. '100u64'). * @property privateFee If true, pay the fee from a private record instead of * the public credits balance. Defaults to false. + * @property feeRecord Optional credits record plaintext pinned by record management. Absent by default; + * adapters MUST use this exact record when supplied with privateFee. * @property programSource Program source, when the caller already has it; fetched otherwise. * @property programImports Import program name → source, for imports the prover cannot fetch. + * @property onProgress Optional awaited checkpoint callback, absent by default. Implementations MUST + * emit transaction-prepared with the full proved transaction before broadcasting when supplied. */ export type ExecuteOptions = { + onProgress?: ProvingProgressHandler + programName: string functionName: string inputs: string[] /** Priority fee in microcredits (1 credit = 1_000_000 microcredits) */ fee: bigint privateFee?: boolean | undefined + feeRecord?: string | undefined programSource?: string | undefined programImports?: Record | undefined } @@ -137,6 +146,20 @@ export type BuildDeploymentOptions = { privateFee?: boolean | undefined } +/** + * Identifies an execution for a base-fee estimate without proving or submitting. + * @property programName Deployed program identifier. + * @property functionName Function to estimate, including its nested calls. + * @property programSource Optional program source; omission fetches it from the configured node. + * @property imports Optional dynamic program names or program-to-source map; omission resolves static imports only. + */ +export type EstimateExecutionFeeOptions = { + programName: string + functionName: string + programSource?: string + imports?: string[] | Record +} + /** * SDK-backed adapter for wallet-side operations that need network binaries. * @@ -148,6 +171,8 @@ export type BuildDeploymentOptions = { * @property {string} [url] - Prover service URL. Required for `mode: 'delegated'`, ignored otherwise. * @property {string} [apiKey] - API key for the prover service, if it requires one. * @property {boolean} [useFeeMaster] - If true, requests that the delegated prover pay transaction fees from its FeeMaster account. Defaults to `false`; enable only when the prover service has granted that capability. + * @property estimateExecutionFee Optional base-fee estimate in microcredits, excluding priority fees. + * Required for managed private fees; may read programs, but MUST NOT prove or submit. * @property generateFreezelistProof Generates and validates an exclusion witness * without signing or network access. Optional; SDK-backed configs supply it. */ @@ -156,6 +181,7 @@ export type ProvingConfig = { url?: string | undefined apiKey?: string | undefined useFeeMaster?: boolean | undefined + estimateExecutionFee?: (options: EstimateExecutionFeeOptions) => Promise generateFreezelistProof?: (options: GenerateFreezelistProofParameters) => Promise buildTransaction?: (options: BuildTransactionOptions) => Promise buildDeployment?: (options: BuildDeploymentOptions) => Promise diff --git a/packages/core/test/fixtures/programs/main_aj_arc20_2_15.aleo b/packages/core/test/fixtures/programs/main_aj_arc20_2_15.aleo new file mode 100644 index 00000000..6781399c --- /dev/null +++ b/packages/core/test/fixtures/programs/main_aj_arc20_2_15.aleo @@ -0,0 +1,389 @@ +program main_aj_arc20_2_15.aleo; + +function join_2: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + assert.eq r1.owner r2.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r3 (as dynamic.record); + output r3 as dynamic.record; + +function join_3: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r4 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r4 r3 (as dynamic.record dynamic.record) into r5 (as dynamic.record); + output r5 as dynamic.record; + +function join_4: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r5 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r3 (as dynamic.record dynamic.record) into r6 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r6 r4 (as dynamic.record dynamic.record) into r7 (as dynamic.record); + output r7 as dynamic.record; + +function join_5: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r6 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r7 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r6 r7 (as dynamic.record dynamic.record) into r8 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r8 r5 (as dynamic.record dynamic.record) into r9 (as dynamic.record); + output r9 as dynamic.record; + +function join_6: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r7 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r8 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r9 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r10 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r10 r9 (as dynamic.record dynamic.record) into r11 (as dynamic.record); + output r11 as dynamic.record; + +function join_7: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r8 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r9 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r10 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r8 r9 (as dynamic.record dynamic.record) into r11 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r10 r7 (as dynamic.record dynamic.record) into r12 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + output r13 as dynamic.record; + +function join_8: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r9 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r10 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r11 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r12 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r13 r14 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + output r15 as dynamic.record; + +function join_9: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r10 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r11 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r12 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r10 r11 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r12 r13 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r14 r15 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r16 r9 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + output r17 as dynamic.record; + +function join_10: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r11 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r12 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r13 r14 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r16 r17 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r18 r15 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + output r19 as dynamic.record; + +function join_11: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + input r11 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + assert.eq r1.owner r11.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r12 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r12 r13 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r14 r15 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r16 r11 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r17 r18 (as dynamic.record dynamic.record) into r20 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r20 r19 (as dynamic.record dynamic.record) into r21 (as dynamic.record); + output r21 as dynamic.record; + +function join_12: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + input r11 as dynamic.record; + input r12 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + assert.eq r1.owner r11.owner; + assert.eq r1.owner r12.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r13 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r13 r14 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r15 r16 (as dynamic.record dynamic.record) into r20 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r17 r18 (as dynamic.record dynamic.record) into r21 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r19 r20 (as dynamic.record dynamic.record) into r22 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r22 r21 (as dynamic.record dynamic.record) into r23 (as dynamic.record); + output r23 as dynamic.record; + +function join_13: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + input r11 as dynamic.record; + input r12 as dynamic.record; + input r13 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + assert.eq r1.owner r11.owner; + assert.eq r1.owner r12.owner; + assert.eq r1.owner r13.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r14 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r14 r15 (as dynamic.record dynamic.record) into r20 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r16 r17 (as dynamic.record dynamic.record) into r21 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r18 r19 (as dynamic.record dynamic.record) into r22 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r20 r21 (as dynamic.record dynamic.record) into r23 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r22 r13 (as dynamic.record dynamic.record) into r24 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r23 r24 (as dynamic.record dynamic.record) into r25 (as dynamic.record); + output r25 as dynamic.record; + +function join_14: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + input r11 as dynamic.record; + input r12 as dynamic.record; + input r13 as dynamic.record; + input r14 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + assert.eq r1.owner r11.owner; + assert.eq r1.owner r12.owner; + assert.eq r1.owner r13.owner; + assert.eq r1.owner r14.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r15 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r20 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r13 r14 (as dynamic.record dynamic.record) into r21 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r15 r16 (as dynamic.record dynamic.record) into r22 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r17 r18 (as dynamic.record dynamic.record) into r23 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r19 r20 (as dynamic.record dynamic.record) into r24 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r22 r23 (as dynamic.record dynamic.record) into r25 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r24 r21 (as dynamic.record dynamic.record) into r26 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r25 r26 (as dynamic.record dynamic.record) into r27 (as dynamic.record); + output r27 as dynamic.record; + +function join_15: + input r0 as identifier.public; + input r1 as dynamic.record; + input r2 as dynamic.record; + input r3 as dynamic.record; + input r4 as dynamic.record; + input r5 as dynamic.record; + input r6 as dynamic.record; + input r7 as dynamic.record; + input r8 as dynamic.record; + input r9 as dynamic.record; + input r10 as dynamic.record; + input r11 as dynamic.record; + input r12 as dynamic.record; + input r13 as dynamic.record; + input r14 as dynamic.record; + input r15 as dynamic.record; + assert.eq r1.owner r2.owner; + assert.eq r1.owner r3.owner; + assert.eq r1.owner r4.owner; + assert.eq r1.owner r5.owner; + assert.eq r1.owner r6.owner; + assert.eq r1.owner r7.owner; + assert.eq r1.owner r8.owner; + assert.eq r1.owner r9.owner; + assert.eq r1.owner r10.owner; + assert.eq r1.owner r11.owner; + assert.eq r1.owner r12.owner; + assert.eq r1.owner r13.owner; + assert.eq r1.owner r14.owner; + assert.eq r1.owner r15.owner; + call.dynamic r0 'aleo' 'join' with r1 r2 (as dynamic.record dynamic.record) into r16 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r3 r4 (as dynamic.record dynamic.record) into r17 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r5 r6 (as dynamic.record dynamic.record) into r18 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r7 r8 (as dynamic.record dynamic.record) into r19 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r9 r10 (as dynamic.record dynamic.record) into r20 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r11 r12 (as dynamic.record dynamic.record) into r21 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r13 r14 (as dynamic.record dynamic.record) into r22 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r16 r17 (as dynamic.record dynamic.record) into r23 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r18 r19 (as dynamic.record dynamic.record) into r24 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r20 r21 (as dynamic.record dynamic.record) into r25 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r22 r15 (as dynamic.record dynamic.record) into r26 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r23 r24 (as dynamic.record dynamic.record) into r27 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r25 r26 (as dynamic.record dynamic.record) into r28 (as dynamic.record); + call.dynamic r0 'aleo' 'join' with r27 r28 (as dynamic.record dynamic.record) into r29 (as dynamic.record); + output r29 as dynamic.record; + +constructor: + assert.eq edition 0u16; diff --git a/packages/core/test/integration/recordInventory.integration.test.ts b/packages/core/test/integration/recordInventory.integration.test.ts new file mode 100644 index 00000000..7291b376 --- /dev/null +++ b/packages/core/test/integration/recordInventory.integration.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createClient, createPublicClient, http, type RecordAsset } from '../../src/index.js' +import { validateAsset } from '../../src/actions/records/internal.js' +import { getRecordJoinPrograms, validateRecordJoinFunction } from '../../src/inventory/joinPrograms.js' + +/** + * Checks inventory defaults and helper discovery against deployed testnet code. + * Reads through the real HTTP transport; no account, scanner, or transaction is needed. + * Run with VEIL_INTEGRATION=1 pnpm vitest run packages/core/test/integration/recordInventory.integration.test.ts. + */ +const RUN = process.env.VEIL_INTEGRATION === '1' +const client = createPublicClient({ transport: http('https://edge.provable.com/api/v2', { network: 'testnet' }) }) + +const assets = [ + { program: 'credits.aleo', recordName: 'credits', amountField: 'microcredits', amountType: 'u64', + helpers: ['autojoin_credits_2_10.aleo', 'autojoin_credits_11_14.aleo', 'autojoin_credits_15_16.aleo'], max: 16 }, + { program: 'test_usdcx_stablecoin.aleo', recordName: 'Token', amountField: 'amount', amountType: 'u128', + helpers: ['aj_test_usdcx_stablecoin_2_10.aleo', 'aj_test_usdcx_stablecoin_11_14.aleo', 'aj_test_usdcx_stablecoin_15_16.aleo'], max: 16 }, + { program: 'test_arc20_eth.aleo', recordName: 'Token', amountField: 'amount', amountType: 'u128', + helpers: ['test_aj_arc20_2_15.aleo'], max: 15 }, +] as const + +describe.runIf(RUN)('live inventory program interfaces', () => { + it.each(assets)('resolves $program without a standard and finds usable batch functions', async expected => { + // A regression in default selection or external/dynamic ABI parsing must + // fail against deployed code, even if local program fixtures still pass. + const asset: RecordAsset = { program: expected.program } + const resolved = await validateAsset(client, asset) + expect(resolved.asset).toMatchObject({ program: expected.program, recordName: expected.recordName, + amountField: expected.amountField, amountType: expected.amountType }) + const helpers = await getRecordJoinPrograms(client, resolved.asset) + expect(helpers.map(helper => helper.program)).toEqual(expected.helpers) + expect([...new Set(helpers.flatMap(helper => helper.counts))].sort((a, b) => a - b)) + .toEqual(Array.from({ length: expected.max - 1 }, (_, index) => index + 2)) + // Validate every supported arity through the spending validator. Interior + // functions must be compatible too, not only the deployment boundaries. + for (const helper of helpers) { + for (const count of helper.counts) await validateRecordJoinFunction(client, resolved.asset, helper, count) + } + }, 120_000) + + it.each(assets)('disables deployed helpers for $program when native joins are configured', async expected => { + const native = createClient({ + transport: http('https://edge.provable.com/api/v2', { network: 'testnet' }), + recordManagement: { enabled: false, tokenJoin: false }, + }) + const resolved = await validateAsset(native, { program: expected.program }) + expect(await getRecordJoinPrograms(native, resolved.asset)).toEqual([]) + }, 120_000) + + it('rejects a static helper for another asset and a count outside its deployed range', async () => { + const usdcx = await validateAsset(client, { program: 'test_usdcx_stablecoin.aleo' }) + const eth = await validateAsset(client, { program: 'test_arc20_eth.aleo' }) + const [helper] = await getRecordJoinPrograms(client, usdcx.asset) + expect(helper).toBeDefined() + await expect(validateRecordJoinFunction(client, eth.asset, helper!, 3)).rejects.toThrow('incompatible function signature') + await expect(validateRecordJoinFunction(client, usdcx.asset, helper!, 11)).rejects.toThrow('outside the join helper limits') + }, 120_000) + + it('accepts explicit record/field overrides and rejects an undeclared balance', async () => { + const explicit = await validateAsset(client, { program: 'test_usdcx_stablecoin.aleo', recordName: 'Token', amountField: 'amount' }) + expect(explicit.asset.amountType).toBe('u128') + await expect(validateAsset(client, { program: 'test_usdcx_stablecoin.aleo', amountField: 'not_a_balance' })) + .rejects.toThrow('amount field') + }, 120_000) +}) diff --git a/packages/core/test/inventory/assetDefaults.test.ts b/packages/core/test/inventory/assetDefaults.test.ts new file mode 100644 index 00000000..e5995d3c --- /dev/null +++ b/packages/core/test/inventory/assetDefaults.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createWalletClient, type RecordAsset } from '../../src/index.js' + +const programSource = (program: string, recordName: string, fields: string, amountType = 'u64') => `program ${program}; +record ${recordName}: + owner as address.private; +${fields} +function join: + input r0 as ${recordName}.record; + input r1 as ${recordName}.record; + output r2 as ${recordName}.record; +function split: + input r0 as ${recordName}.record; + input r1 as ${amountType}.private; + output r2 as ${recordName}.record; + output r3 as ${recordName}.record; +` +function wallet(program: string, recordName: string, fields: Record, source: string) { + const records = [1, 2].map(n => ({ programName: program, recordName, uid: `wallet-${n}`, commitment: `${n}field`, + recordView: { fields: { $nonce: `${n}group`, ...fields } } })) + const request = vi.fn(async ({ method, params }: any) => { + if (method === 'getProgram' && params.programId === program) return source + if (method === 'getProgram') throw Object.assign(new Error('Program not found'), { status: 404 }) + if (method === 'requestRecords') return records + if (method === 'executeTransaction') return 'at1submitted' + throw new Error(method) + }) + const client = createWalletClient({ account: { type: 'rpc', address: 'aleo1owner', sign: vi.fn(), signMessage: vi.fn() }, + transport: { config: { type: 'custom', key: 'test', name: 'test', network: 'testnet', request }, request } }) + return { client, request, records } +} + +describe('record asset defaults and overrides', () => { + it.each([ + { program: 'credits.aleo', record: 'credits', field: 'microcredits', width: 'u64' }, + { program: 'test_arc20_eth.aleo', record: 'Token', field: 'amount', width: 'u128' }, + { program: 'test_usdcx_stablecoin.aleo', record: 'Token', field: 'amount', width: 'u128' }, + ])('reads $program without a standard and pins the default record for joins', async ({ program, record, field, width }) => { + const source = program === 'credits.aleo' ? programSource(program, record, ` ${field} as ${width}.private;`, width) + : readFileSync(new URL(`../fixtures/programs/${program}`, import.meta.url), 'utf8') + const { client, request } = wallet(program, record, { [field]: `50000${width}` }, source) + const asset: RecordAsset = { program } + const inventory = await client.getRecordInventory({ asset }) + expect(inventory.balance).toBe(100000n) + await client.joinRecords({ asset, records: inventory.available }) + expect(request).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'executeTransaction', params: expect.objectContaining({ + programName: program, functionName: 'join', inputs: [1, 2].map(n => ({ type: 'record', program, recordname: record, uid: `wallet-${n}` })), + }) })) + expect(client.recordManagement).toBeUndefined() + }) + + it.each([ + { fields: ' amount as u64.private;\n microcredits as u64.private;', grants: { amount: '7u64', microcredits: '99u64' }, want: 14n }, + { fields: ' microcredits as u64.private;', grants: { microcredits: '9u64' }, want: 18n }, + ])('prefers amount and falls back to microcredits in a custom record', async ({ fields, grants, want }) => { + const { client } = wallet('custom.aleo', 'Balance', grants, programSource('custom.aleo', 'Balance', fields)) + expect((await client.getRecordInventory({ asset: { program: 'custom.aleo' } })).balance).toBe(want) + }) + + it('uses explicit record and field overrides for scans, wallet inputs, splits, and plans', async () => { + const { client, request } = wallet('custom.aleo', 'Balance', { value: '100u64', amount: '999u64' }, + programSource('custom.aleo', 'Balance', ' value as u64.private;\n amount as u64.private;')) + const asset: RecordAsset = { program: 'custom.aleo', recordName: 'Balance', amountField: 'value' } + const inventory = await client.getRecordInventory({ asset }) + expect(inventory.balance).toBe(200n) + const plan = await client.planRecordInventory({ asset, target: { records: 4 } }) + expect(plan.asset).toMatchObject(asset) + expect(plan.steps.map(step => step.amount)).toEqual([50n, 50n]) + await client.splitRecord({ asset, record: inventory.available[0]!, amount: 25n }) + expect(request).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'executeTransaction', params: expect.objectContaining({ + inputs: [{ type: 'record', program: 'custom.aleo', recordname: 'Balance', uid: 'wallet-1' }, '25u64'], + }) })) + }) + + it('rejects a missing override instead of spending a different amount field', async () => { + const { client, request } = wallet('custom.aleo', 'Balance', { amount: '100u64' }, + programSource('custom.aleo', 'Balance', ' amount as u64.private;')) + await expect(client.getRecordInventory({ asset: { program: 'custom.aleo', amountField: 'missing' } })).rejects.toThrow('amount field') + expect(request.mock.calls.some(([call]) => call.method === 'executeTransaction')).toBe(false) + }) + + it('does not fall back to a granted microcredits field when the selected amount grant is missing', async () => { + const { client } = wallet('custom.aleo', 'Balance', { microcredits: '100u64' }, + programSource('custom.aleo', 'Balance', ' amount as u64.private;\n microcredits as u64.private;')) + await expect(client.getRecordInventory({ asset: { program: 'custom.aleo' } })).rejects.toThrow('amount grant') + }) + + it('enforces the deployed custom amount width before joining', async () => { + const { client } = wallet('custom.aleo', 'Balance', { quantity: `${(1n << 64n) - 1n}u64` }, + programSource('custom.aleo', 'Balance', ' quantity as u64.private;')) + const asset: RecordAsset = { program: 'custom.aleo', recordName: 'Balance', amountField: 'quantity' } + const records = (await client.getRecordInventory({ asset })).available + await expect(client.joinRecords({ asset, records })).rejects.toThrow('overflows') + }) + + it('selects the native join record even when an unrelated Token record exists', async () => { + const source = programSource('custom.aleo', 'Balance', ' amount as u64.private;') + + '\nrecord Token:\n owner as address.private;\n amount as u128.private;' + const { client } = wallet('custom.aleo', 'Balance', { amount: '5u64' }, source) + const inventory = await client.getRecordInventory({ asset: { program: 'custom.aleo' } }) + expect(inventory.asset.recordName).toBe('Balance') + expect(inventory.balance).toBe(10n) + }) + + it.each(['token_id as field', 'sender as address', 'recipient as address'])('excludes opaque non-fungible/compliance records declaring %s', async declaration => { + const { client } = wallet('custom.aleo', 'Balance', { amount: '5u64' }, + programSource('custom.aleo', 'Balance', ` amount as u64.private;\n ${declaration}.private;`)) + expect((await client.getRecordInventory({ asset: { program: 'custom.aleo' } })).available).toEqual([]) + }) + + it('uses the custom record name and u32 amount field with a local scanner and prover', async () => { + const source = programSource('custom.aleo', 'Balance', ' quantity as u32.private;', 'u32') + const record = { programName: 'custom.aleo', recordName: 'Balance', commitment: '1field', + recordPlaintext: '{ owner: aleo1owner.private, quantity: 100u32.private, _nonce: 1group.public }' } + const scan = vi.fn(async () => [record]) + const build = vi.fn(async () => ({ id: 'at1local', type: 'execute', execution: { transitions: [] } } as any)) + const request = vi.fn(async ({ method }: any) => method === 'getProgram' ? source : 'at1local') + const client = createWalletClient({ account: { type: 'local', address: 'aleo1owner', source: 'test', + privateKey: 'test', viewKey: 'test', sign: vi.fn(), signMessage: vi.fn() }, + transport: { config: { type: 'custom', key: 'test', name: 'test', network: 'testnet', request }, request }, + recordProvider: { requestRecords: scan, setAccount() {} }, proving: { mode: 'local', buildTransaction: build } }) + const asset = { program: 'custom.aleo', recordName: 'Balance', amountField: 'quantity' } + const inventory = await client.getRecordInventory({ asset }) + expect(scan).toHaveBeenCalledWith(expect.objectContaining({ filter: expect.objectContaining({ records: ['Balance'] }) })) + expect(inventory.balance).toBe(100n) + await client.splitRecord({ asset, record: inventory.available[0]!, amount: 25n }) + expect(build).toHaveBeenCalledWith(expect.objectContaining({ inputs: [record.recordPlaintext, '25u32'] })) + }) + +}) diff --git a/packages/core/test/inventory/autojoinHelpers.test.ts b/packages/core/test/inventory/autojoinHelpers.test.ts new file mode 100644 index 00000000..ca4b81d0 --- /dev/null +++ b/packages/core/test/inventory/autojoinHelpers.test.ts @@ -0,0 +1,206 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createWalletClient, memoryRecordInventoryStore, type RecordAsset, type RecordManagementConfig, type Transaction } from '../../src/index.js' +import { inventoryOutputCommitments } from '../../src/inventory/tokenJoin.js' + +// These ABI fixtures use the external-record signature of the deployed credits +// helpers and the dynamic signature of the checked-in ARC helper program. +function staticHelper(program: string, asset: string, record: string, min: number, max: number) { + return `import ${asset};\nprogram ${program};\n` + Array.from({ length: max - min + 1 }, (_, i) => { + const n = i + min + return `function join_${n}:\n` + Array.from({ length: n }, (_, j) => ` input r${j} as ${asset}/${record}.record;\n`).join('') + + ` output r${n} as ${asset}/${record}.record;\n` + }).join('\n') +} +function world(program = 'credits.aleo', options: { network?: 'mainnet' | 'testnet' | null; helpers?: boolean; tokenJoin?: false; enabled?: boolean } = {}) { + const isCredits = program === 'credits.aleo' + const name = isCredits ? 'credits' : 'Token', field = isCredits ? 'microcredits' : 'amount', width = isCredits ? 'u64' : 'u128' + const network = options.network === undefined ? 'testnet' : options.network + let next = 100, records: any[] = [] + const sources = new Map() + if (!isCredits) sources.set(program, readFileSync(new URL(`../fixtures/programs/${program}`, import.meta.url), 'utf8')) + if (options.helpers !== false) { + const prefix = isCredits ? 'autojoin_credits' : `aj_${program.slice(0, -5)}` + for (const [min, max] of [[2, 10], [11, 14], [15, 16]]) { + const helper = `${prefix}_${min}_${max}.aleo` + sources.set(helper, staticHelper(helper, program, name, min!, max!)) + } + const dynamic = `${network === 'mainnet' ? 'main' : 'test'}_aj_arc20_2_15.aleo` + sources.set(dynamic, readFileSync(new URL('../fixtures/programs/main_aj_arc20_2_15.aleo', import.meta.url), 'utf8') + .replaceAll('main_aj_arc20_2_15.aleo', dynamic)) + } + const makeRecord = (amount: bigint, n: number) => ({ programName: program, recordName: name, uid: `wallet-${n}`, commitment: `${n}field`, spent: false, + recordView: { fields: { $nonce: `${n}group`, [field]: `${amount}${width}` } } }) + const transactions = new Map() + const calls: any[] = [] + const store = memoryRecordInventoryStore() + const request = vi.fn(async ({ method, params }: any) => { + if (method === 'getProgram') { + if (sources.has(params.programId)) return sources.get(params.programId) + throw Object.assign(new Error('Program not found'), { status: 404 }) + } + if (method === 'requestRecords') return records.filter(record => !record.spent) + if (method === 'getConfirmedTransaction') return { status: 'accepted', transaction: transactions.get(params.id) } + if (method !== 'executeTransaction') throw new Error(method) + calls.push(params) + const selected = params.inputs.filter((input: any) => typeof input === 'object').map((input: any) => records.find(record => record.uid === input.uid)!) + expect(selected.every((record: any) => record && !record.spent)).toBe(true) + const sum = selected.reduce((total: bigint, record: any) => total + BigInt(record.recordView.fields[field].slice(0, -width.length)), 0n) + const amounts = params.functionName === 'split' + ? [BigInt(params.inputs[1].slice(0, -width.length)), sum - BigInt(params.inputs[1].slice(0, -width.length)) - (isCredits ? 10000n : 0n)] : [sum] + const outputs = amounts.map(amount => makeRecord(amount, next++)) + selected.forEach((record: any) => { record.spent = true }) + records.push(...outputs) + const dynamic = typeof params.inputs[0] === 'string' + const external = !dynamic && params.programName !== program + const transaction = { id: `at1tx${calls.length}`, type: 'execute', execution: { transitions: [ + ...(external ? Array.from({ length: selected.length - 1 }, (_, i) => ({ + id: `au1nested${i}`, program, function: 'join', + outputs: [{ type: 'record', id: i === selected.length - 2 ? outputs[0]!.commitment : `intermediate-${i}field` }], + })) : []), + ...(dynamic ? [{ id: 'au1inner', program, function: 'join', outputs: [{ type: 'record_with_dynamic_id', id: outputs[0]!.commitment, dynamic_id: 'dynamicfield' }] }] : []), + { id: 'au1outer', program: params.programName, function: params.functionName, + outputs: dynamic ? [{ type: 'record_dynamic', id: 'outerdynamicfield' }] + : external ? [{ type: 'external_record', id: 'externalfield' }] : outputs.map(record => ({ type: 'record', id: record.commitment, value: 'record1ciphertext' })) }, + ] } } + transactions.set(transaction.id, transaction) + return transaction.id + }) + const recordManagement: RecordManagementConfig = { enabled: options.enabled ?? true, store, tokenJoin: options.tokenJoin } + const client = createWalletClient({ account: { type: 'rpc', address: 'aleo1owner', sign: vi.fn(), signMessage: vi.fn() }, + transport: { config: { type: 'custom', key: 'test', name: 'test', network, request }, request }, + recordManagement: { ...recordManagement, chainId: network ?? 'local:test' } }) + return { client, sources, calls, store, request, setAmounts: (amounts: bigint[]) => { records = amounts.map((amount, i) => makeRecord(amount, i + 1)) } } +} + +describe('automatic join helper selection', () => { + it.each([ + [3, 'autojoin_credits_2_10.aleo'], [11, 'autojoin_credits_11_14.aleo'], + [15, 'autojoin_credits_15_16.aleo'], [16, 'autojoin_credits_15_16.aleo'], + ])('uses one helper transaction for %i credits records', async (count, helper) => { + const w = world(); w.setAmounts(Array.from({ length: count as number }, () => 100n)) + const plan = await w.client.planRecordInventory({ asset: { program: 'credits.aleo' }, target: { records: 1 }, maxTransactions: 1 }) + expect(plan.steps).toHaveLength(1) + expect(await w.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'complete', completedSteps: 1 }) + expect(w.calls[0]).toMatchObject({ programName: helper, functionName: `join_${count}` }) + expect(w.calls[0].inputs).toHaveLength(count as number) + expect((await w.client.getRecordInventory({ asset: { program: 'credits.aleo' } })).balance).toBe(BigInt(count) * 100n) + expect((await w.store.list(JSON.stringify(['testnet', 'aleo1owner'])))[0]).toMatchObject({ assetProgram: 'credits.aleo', status: 'confirmed' }) + }) + + it('chains a 16-record helper output into a native join for 17 credits records', async () => { + const w = world(); w.setAmounts(Array.from({ length: 17 }, () => 100n)) + expect(await w.client.autoJoin({ asset: { program: 'credits.aleo' }, maxTransactions: 2 })).toMatchObject({ status: 'complete', completedSteps: 2 }) + expect(w.calls.map(call => [call.programName, call.functionName])).toEqual([ + ['autojoin_credits_15_16.aleo', 'join_16'], ['credits.aleo', 'join'], + ]) + expect(w.calls[1].inputs.some((input: any) => input.uid === 'wallet-100')).toBe(true) + }) + + it('prefers a dedicated stablecoin helper over a generic ARC helper', async () => { + const w = world('test_usdcx_stablecoin.aleo'); w.setAmounts([10n, 20n, 30n]) + const asset: RecordAsset = { program: 'test_usdcx_stablecoin.aleo' } + await w.client.joinRecords({ asset, records: (await w.client.getRecordInventory({ asset })).available }) + expect(w.calls[0]).toMatchObject({ programName: 'aj_test_usdcx_stablecoin_2_10.aleo', functionName: 'join_3' }) + expect(w.calls[0].inputs.every((input: any) => typeof input === 'object')).toBe(true) + }) + + it.each(['mainnet', 'testnet'] as const)('uses the %s dynamic ARC helper with a program identifier', async network => { + const w = world('test_arc20_eth.aleo', { network }); w.setAmounts([10n, 20n, 30n]) + const asset = { program: 'test_arc20_eth.aleo' } + expect(await w.client.autoJoin({ asset, maxTransactions: 1 })).toMatchObject({ status: 'complete', completedSteps: 1 }) + expect(w.calls[0]).toMatchObject({ programName: `${network === 'mainnet' ? 'main' : 'test'}_aj_arc20_2_15.aleo`, functionName: 'join_3', + imports: [asset.program], inputs: ["'test_arc20_eth'", expect.any(Object), expect.any(Object), expect.any(Object)] }) + }) + + it.each([{ helpers: false }, { tokenJoin: false as const }, { network: null }])('falls back to native joins for unavailable, disabled, or unknown-network helpers: %j', async options => { + const w = world('credits.aleo', options); w.setAmounts([100n, 100n, 100n]) + expect(await w.client.autoJoin({ asset: { program: 'credits.aleo' } })).toMatchObject({ status: 'complete', completedSteps: 2 }) + expect(w.calls.map(call => call.programName)).toEqual(['credits.aleo', 'credits.aleo']) + }) + + it('rejects a mismatched external record ABI before choosing a helper', async () => { + const w = world(); w.setAmounts([100n, 100n, 100n]) + w.sources.set('autojoin_credits_2_10.aleo', staticHelper('autojoin_credits_2_10.aleo', 'other.aleo', 'credits', 2, 10)) + const plan = await w.client.planRecordInventory({ asset: { program: 'credits.aleo' }, target: { records: 1 } }) + expect(plan.steps.map(step => step.inputs.length)).toEqual([2, 2]) + }) + + it('surfaces helper lookup outages without submitting native transactions', async () => { + const w = world(); w.setAmounts([100n, 100n, 100n]) + // Keep wallet inventory available while the public helper source lookup fails. + const inventory = [1, 2, 3].map(n => ({ programName: 'credits.aleo', recordName: 'credits', uid: `wallet-${n}`, + recordView: { fields: { $nonce: `${n}group`, microcredits: '100u64' } } })) + w.request.mockImplementation(async ({ method }: any) => { + if (method === 'requestRecords') return inventory + throw Object.assign(new Error('Gateway unavailable'), { status: 503 }) + }) + await expect(w.client.autoJoin({ asset: { program: 'credits.aleo' } })).rejects.toThrow('Gateway unavailable') + expect(w.calls).toHaveLength(0) + }) + + it('keeps the helper chosen during planning and rejects a changed helper ABI before submission', async () => { + const w = world(); w.setAmounts([100n, 100n, 100n]) + const plan = await w.client.planRecordInventory({ asset: { program: 'credits.aleo' }, target: { records: 1 } }) + w.sources.set('autojoin_credits_2_10.aleo', staticHelper('autojoin_credits_2_10.aleo', 'other.aleo', 'credits', 2, 10)) + expect(await w.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'interrupted', completedSteps: 0, reason: expect.stringContaining('incompatible') }) + expect(w.calls).toHaveLength(0) + }) + + it('uses helpers for an explicit join without enabling coordination', async () => { + const w = world('credits.aleo', { enabled: false }); w.setAmounts([100n, 100n, 100n]) + const asset = { program: 'credits.aleo' } + await w.client.joinRecords({ asset, records: (await w.client.getRecordInventory({ asset })).available }) + expect(w.calls[0].functionName).toBe('join_3') + expect(await w.store.list(JSON.stringify(['testnet', 'aleo1owner']))).toEqual([]) + }) +}) + +// Static helper return ids are external commitments, not the record commitments +// supplied by the scanner. These fixtures mirror accepted testnet transactions. +describe('confirmed static helper outputs', () => { + const asset = { program: 'test_usdcx_stablecoin.aleo' } + const step = { kind: 'join' as const, deduction: 0n, inputs: ['a', 'b', 'c'], outputs: [{ id: 'joined', amount: 6n }], + joinProgram: { program: 'aj_test_usdcx_stablecoin_2_10.aleo', counts: [3], inputMode: 'records' as const } } + const nested = (program: string, id: string) => ({ program, function: 'join', outputs: [{ type: 'record', id }] }) + const outer = { program: step.joinProgram.program, function: 'join_3', outputs: [{ type: 'external_record', id: 'externalfield' }] } + const transaction = (transitions: unknown[]) => ({ execution: { transitions } }) as Transaction + + it('selects the final native join and excludes consumed intermediate records', () => { + expect(inventoryOutputCommitments(transaction([nested(asset.program, 'intermediatefield'), + nested(asset.program, 'finalfield'), outer]), asset, step)).toEqual(['finalfield']) + }) + + it.each([[], [nested('other.aleo', 'wrongfield')], + [{ ...nested(asset.program, 'wrongfield'), function: 'split' }], + [{ program: asset.program, function: 'join', outputs: [] }]].map(calls => ({ calls })))('rejects a missing or mismatched final native join: %j', ({ calls }) => { + expect(() => inventoryOutputCommitments(transaction([...calls, outer]), asset, step)).toThrow() + }) +}) + + +// Each transition commits its dynamic return separately. The outer helper id +// therefore differs from the dynamic_id attached to the native record output. +describe('confirmed dynamic helper outputs', () => { + const asset = { program: 'test_arc20_eth.aleo' } + const router = { program: 'test_aj_arc20_2_15.aleo' } + const base = { kind: 'join' as const, deduction: 0n, inputs: ['a', 'b', 'c'], outputs: [{ id: 'joined', amount: 6n }] } + const helper = { ...router, counts: [3], inputMode: 'programIdentifierThenRecords' as const } + const native = (id: string) => ({ program: asset.program, function: 'join', + outputs: [{ type: 'record_with_dynamic_id', id, dynamic_id: `native-${id}` }] }) + const outer = { program: router.program, function: 'join_3', outputs: [{ type: 'record_dynamic', id: 'outerfield' }] } + const transaction = (transitions: unknown[]) => ({ execution: { transitions } }) as Transaction + + it.each([true, false])('resolves distinct outer and nested dynamic ids (automatic helper: %s)', automatic => { + const step = automatic ? { ...base, joinProgram: helper } : base + expect(inventoryOutputCommitments(transaction([native('intermediatefield'), native('finalfield'), outer]), + asset, step, automatic ? undefined : router)).toEqual(['finalfield']) + }) + + it('rejects a final call from a different token even when its dynamic id matches the helper', () => { + const wrong = { ...native('wrongfield'), program: 'other.aleo', + outputs: [{ type: 'record_with_dynamic_id', id: 'wrongfield', dynamic_id: 'outerfield' }] } + expect(() => inventoryOutputCommitments(transaction([native('intermediatefield'), wrong, outer]), + asset, { ...base, joinProgram: helper })).toThrow() + }) +}) diff --git a/packages/core/test/inventory/configuration.test.ts b/packages/core/test/inventory/configuration.test.ts new file mode 100644 index 00000000..7450dde8 --- /dev/null +++ b/packages/core/test/inventory/configuration.test.ts @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createClient, createWalletClient, memoryRecordInventoryStore, recordActions, reservedRecordIds, type RecordManagementConfig } from '../../src/index.js' + +import { walletActions } from '../../src/clients/decorators/wallet.js' + +const asset = { program: 'credits.aleo', standard: 'credits' } as const +const account = { type: 'rpc' as const, address: 'aleo1owner', sign: vi.fn(), signMessage: vi.fn() } +const scope = JSON.stringify(['aleo:testnet', account.address]) +const record = (nonce: number) => ({ + programName: asset.program, recordName: 'credits', uid: `wallet-${nonce}`, commitment: `${nonce}field`, + recordView: { fields: { $nonce: `${nonce}group`, microcredits: '100000u64' } }, +}) +function transport(network: 'testnet' | undefined = 'testnet') { + const request = vi.fn(async ({ method }: any) => { + if (method === 'getProgram') return readFileSync(new URL('../fixtures/programs/credits.aleo', import.meta.url), 'utf8') + if (method === 'requestRecords') return [record(1), record(2)] + if (method === 'executeTransaction') return 'at1wallet' + if (method === 'getConfirmedTransaction') return { status: 'accepted', transaction: { + id: 'at1wallet', type: 'execute', execution: { transitions: [{ + id: 'au1split', program: asset.program, function: 'split', outputs: [{ type: 'record', value: 'record1output' }], + }] }, + } } + throw new Error(`Unexpected request: ${method}`) + }) + return { config: { type: 'custom', key: 'test', name: 'Test', network, request }, request } +} +const pinned = { program: asset.program, function: 'split', inputs: [{ type: 'record' as const, program: asset.program, recordname: 'credits', uid: 'wallet-1' }, '10000u64'] } + +describe('explicit record management configuration', () => { + it.each([undefined, false].flatMap((enabled) => + (['writeContract', 'executeTransaction', 'executeContract'] as const).map((action) => ({ enabled, action }))))( + 'keeps $action unchanged when management is $enabled, even with recordActions', async ({ enabled, action }) => { + const store = memoryRecordInventoryStore() + const client = createWalletClient({ account, transport: transport(), + ...(enabled === undefined ? {} : { recordManagement: { enabled, store, chainId: 'aleo:testnet' } }), + }).extend(recordActions({ store, chainId: 'aleo:testnet', maxFeeMicrocredits: 0n })) + // These inputs are accepted by an unmanaged wallet; coordination would reject both. + const result = await client[action]({ program: asset.program, function: 'join', + inputs: ['record1ciphertext', { type: 'record', program: asset.program, recordname: 'credits' }], privateFee: true, + }) + if (action === 'executeContract') expect(result).toMatchObject({ transactionId: 'at1wallet', outputs: ['record1output'] }) + else expect(result).toBe('at1wallet') + expect(await store.list(scope)).toEqual([]) + expect(await reservedRecordIds(client)).toEqual(new Set()) + }) + + it.each(['run', 'client'] as const)('rejects an RPC %s fee ceiling before scanning a nonempty plan', async (source) => { + const rpc = transport() + const store = memoryRecordInventoryStore() + const client = createWalletClient({ account, transport: rpc, + recordManagement: { enabled: true, store, chainId: 'aleo:testnet', + ...(source === 'client' ? { maxFeeMicrocredits: 0n } : {}) } }) + const plan = await client.planRecordInventory({ asset, target: { records: 1 } }) + expect(plan.steps).not.toHaveLength(0) + rpc.request.mockClear() + await expect(client.rebalanceRecordInventory({ plan, + ...(source === 'run' ? { maxFeeMicrocredits: 0n } : {}) })) + .rejects.toThrow('Wallet accounts cannot enforce a fee ceiling before submission') + expect(rpc.request).not.toHaveBeenCalled() + expect(await store.list(scope)).toEqual([]) + }) + + it.each(['run', 'client'] as const)('allows an RPC no-op plan with a %s fee ceiling', async (source) => { + const rpc = transport() + const store = memoryRecordInventoryStore() + const client = createWalletClient({ account, transport: rpc, + recordManagement: { enabled: true, store, chainId: 'aleo:testnet', + ...(source === 'client' ? { maxFeeMicrocredits: 0n } : {}) } }) + const plan = await client.planRecordInventory({ asset, target: { records: 2 } }) + expect(plan.steps).toEqual([]) + rpc.request.mockClear() + await expect(client.rebalanceRecordInventory({ plan, + ...(source === 'run' ? { maxFeeMicrocredits: 0n } : {}) })) + .resolves.toEqual({ status: 'complete', completedSteps: 0, transactionIds: [] }) + expect(rpc.request).not.toHaveBeenCalled() + expect(await store.list(scope)).toEqual([]) + }) + + it('adds methods without needing a chain or creating a journal', async () => { + const rpc = transport() + rpc.config.network = undefined + const base = createWalletClient({ account, transport: rpc }) + const client = base.extend(recordActions()) + expect(client.recordManagement).toBeUndefined() + expect(client.writeContract).toBe(base.writeContract) + await expect(client.writeContract({ program: asset.program, function: 'join', inputs: [], privateFee: true })).resolves.toBe('at1wallet') + }) + + it('exposes reads, plans, native joins, and splits through walletActions while disabled', async () => { + const client = createWalletClient({ account, transport: transport(), recordManagement: { enabled: false, chainId: 'aleo:testnet' } }) + const inventory = await client.getRecordInventory({ asset }) + expect(inventory.available).toHaveLength(2) + expect(inventory.reserved).toEqual([]) + const plan = await client.planRecordInventory({ asset, target: { records: 1 } }) + expect(plan.scope).toBe(scope) + expect(plan.steps).toHaveLength(1) + await expect(client.joinRecords({ asset, records: inventory.available })).resolves.toBe('at1wallet') + await expect(client.splitRecord({ asset, record: inventory.available[0]!, amount: 10000n })).resolves.toBe('at1wallet') + expect(client.recordManagement?.store).toBeUndefined() + await expect(client.rebalanceRecordInventory({ plan })).rejects.toThrow('recordManagement') + await expect(client.reconcileRecordInventory()).rejects.toThrow('recordManagement') + }) + + it.each((['wallet', 'base'] as const).flatMap((factory) => + (['writeContract', 'executeTransaction', 'executeContract'] as const).map((action) => ({ factory, action }))))( + 'journals $action when explicitly enabled on the $factory factory before action binding', async ({ factory, action }) => { + const store = memoryRecordInventoryStore() + const config = { account, transport: transport(), recordManagement: { enabled: true, store, chainId: 'aleo:testnet' } satisfies RecordManagementConfig } + const client = factory === 'wallet' ? createWalletClient(config) : createClient(config).extend(walletActions) + const result = await client[action](pinned) + if (action === 'executeContract') expect(result).toMatchObject({ transactionId: 'at1wallet', outputs: ['record1output'] }) + else expect(result).toBe('at1wallet') + expect(await store.list(scope)).toMatchObject([{ records: ['1group'], + status: action === 'executeContract' ? 'confirmed' : 'submitted', transactionId: 'at1wallet' }]) + expect(await reservedRecordIds(client)).toEqual(new Set(['1group'])) + await expect(client[action](pinned)).rejects.toThrow('already reserved') + }) + + it('keeps the configured journal when adding compatibility or protocol decorators', async () => { + const store = memoryRecordInventoryStore() + const unusedStore = memoryRecordInventoryStore() + const base = createWalletClient({ account, transport: transport(), recordManagement: { enabled: true, store, chainId: 'aleo:testnet' } }) + const client = base.extend(recordActions({ store: unusedStore })).extend((inner) => ({ spend: () => inner.writeContract(pinned) })) + await expect(client.spend()).resolves.toBe('at1wallet') + expect(await store.list(scope)).toHaveLength(1) + expect(await unusedStore.list(scope)).toEqual([]) + expect(client.writeContract).toBe(base.writeContract) + }) + + it('enforces managed private-fee and uid restrictions only after explicit opt-in', async () => { + const client = createWalletClient({ account, transport: transport(), recordManagement: { enabled: true } }) + await expect(client.writeContract({ ...pinned, privateFee: true })).rejects.toThrow('wallet protocol cannot pin') + await expect(client.writeContract({ ...pinned, inputs: [{ type: 'record', program: asset.program, recordname: 'credits' }] })).rejects.toThrow('pinned record uid') + }) + + it('rejects a network switch on the original managed action closures', async () => { + const rpc = transport() + const client = createWalletClient({ account, transport: rpc, recordManagement: { enabled: true, chainId: 'aleo:testnet' } }) + rpc.config.network = undefined + await expect(client.writeContract(pinned)).rejects.toThrow('network changed') + }) +}) diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts new file mode 100644 index 00000000..ba4e6e2c --- /dev/null +++ b/packages/core/test/inventory/inventory.test.ts @@ -0,0 +1,505 @@ +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createWalletClient, recordActions, memoryRecordInventoryStore, parseRecord, type OwnedRecord, type RecordAsset, type TokenJoinRouter } from '../../src/index.js' +import { buildInventoryPlan } from '../../src/inventory/planner.js' + +const credits: RecordAsset = { program: 'credits.aleo', standard: 'credits' } +const arc20: RecordAsset = { program: 'test_arc20_eth.aleo', standard: 'arc20' } +const arc22: RecordAsset = { program: 'test_usdcx_stablecoin.aleo', standard: 'arc22' } +const source = (asset: RecordAsset) => readFileSync(new URL(`../fixtures/programs/${asset.program}`, import.meta.url), 'utf8') +const scope = JSON.stringify(['testnet', 'aleo1owner']) +const router: TokenJoinRouter = { program: 'test_aj_arc20_2_15.aleo' } +const routerSource = readFileSync(new URL('../fixtures/programs/main_aj_arc20_2_15.aleo', import.meta.url), 'utf8') + .replace('main_aj_arc20_2_15.aleo', router.program) +const makeRecord = (amount: bigint, nonce: number, asset = credits, extra = ''): OwnedRecord => ({ + programName: asset.program, recordName: asset.standard === 'credits' ? 'credits' : 'Token', + tag: `${nonce}field`, commitment: `${nonce}field`, spent: false, + recordPlaintext: `{ owner: aleo1owner.private, ${asset.standard === 'credits' ? 'microcredits' : 'amount'}: ${amount}${asset.standard === 'credits' ? 'u64' : 'u128'}.private, ${extra}_nonce: ${nonce}group.public }`, +}) + +function world(amounts: bigint[], asset = credits, tokenJoin?: TokenJoinRouter) { + let records = amounts.map((amount, index) => makeRecord(amount, index + 1, asset)) + let next = 2000 + let sequence = 0 + const transactions = new Map() + const store = memoryRecordInventoryStore() + const scan = vi.fn(async (params: any) => { + let found = records.filter((record) => params.statusFilter !== 'unspent' || !record.spent) + if (params.filter?.commitments) found = found.filter((record) => params.filter.commitments.includes(record.commitment)) + return found.slice((params.filter?.page ?? 0) * 1000, ((params.filter?.page ?? 0) + 1) * 1000) + }) + const send = vi.fn(async ({ method, params }: any) => { + if (method === 'getProgram') { + if (params.programId === tokenJoin?.program) return routerSource + if (params.programId === asset.program) return source(asset) + throw Object.assign(new Error('Program not found'), { status: 404 }) + } + if (method === 'getConfirmedTransaction') { + const transaction = transactions.get(params.id) + if (!transaction) throw Object.assign(new Error('Not found'), { status: 404 }) + return { status: 'accepted', transaction } + } + if (method !== 'sendTransaction') throw new Error(method) + const transaction = JSON.parse(params.transaction) + const transition = transaction.execution.transitions[0] + for (const id of transaction.testInputs) { + const record = records.find((record) => parseRecord(record.recordPlaintext).nonce === id) + if (record) record.spent = true + } + records.push(...transaction.testOutputs) + transactions.set(transaction.id, transaction) + return transaction.id + }) + const build = vi.fn(async (params: any) => { + const inputs = params.inputs.filter((input: string) => input.startsWith('{')).map((input: string) => parseRecord(input)) + const value = (record: any) => record.fields[asset.standard === 'credits' ? 'microcredits' : 'amount'].value as bigint + const amounts = params.functionName.startsWith('join') ? [inputs.reduce((sum: bigint, record: any) => sum + value(record), 0n)] : + [BigInt(params.inputs[1].replace(/u(64|128)$/, '')), value(inputs[0]) - BigInt(params.inputs[1].replace(/u(64|128)$/, '')) - (asset.standard === 'credits' ? 10_000n : 0n)] + const outputs = amounts.map((amount) => makeRecord(amount, next++, asset)) + const transaction = { id: `at1tx${++sequence}`, type: 'execute', + fee: { transition: { inputs: [{ value: '100u64' }, { value: '0u64' }] } }, + execution: { transitions: params.functionName.startsWith('join_') ? [ + { id: 'au1intermediate', program: asset.program, function: 'join', outputs: [ + { id: 'intermediatefield', type: 'record_with_dynamic_id', dynamic_id: 'intermediatedynamicfield' }] }, + { id: `au1nested${sequence}`, program: asset.program, function: 'join', outputs: outputs.map((record) => + ({ id: record.commitment, type: 'record_with_dynamic_id', dynamic_id: `dynamic${sequence}field`, value: 'record1ciphertext' })) }, + { id: `au1outer${sequence}`, program: params.programName, function: params.functionName, + outputs: [{ id: `outerdynamic${sequence}field`, type: 'record_dynamic' }] }, + ] : [{ id: `au1${sequence}`, program: asset.program, function: params.functionName, + outputs: outputs.map((record) => ({ id: record.commitment, type: 'record', value: 'record1ciphertext' })) }] }, + testInputs: inputs.map((record: any) => record.nonce), testOutputs: outputs } + return transaction as any + }) + const account = { type: 'local' as const, address: 'aleo1owner', source: 'test', privateKey: 'test', viewKey: 'test', sign: vi.fn(), signMessage: vi.fn() } + const transport = { config: { type: 'custom', key: 'test', name: 'Test', network: 'testnet' as const, request: send }, request: send } + const client = createWalletClient({ account, transport, proving: { mode: 'local', buildTransaction: build }, + recordProvider: { requestRecords: scan, setAccount() {} }, recordManagement: { enabled: true, store, tokenJoin } }) + return { client, store, build, send, scan, transactions, records, setRecords: (value: OwnedRecord[]) => { records = value } } +} + +describe('inventory planning', () => { + const plan = (amounts: bigint[], count: number, asset = arc20, distribution: 'balanced' | 'preserve' = 'balanced') => buildInventoryPlan({ + scope, asset, target: { records: count, distribution }, inputs: amounts.map((amount, i) => ({ id: `${i}group`, amount })), + }) + it('splits one ARC token record into exact balanced lots', () => { + const result = plan([100n], 4) + expect(result.steps).toHaveLength(3) + expect(result.steps.map((step) => step.outputs[0]!.amount)).toEqual([25n, 25n, 25n]) + expect(result.deduction).toBe(0n) + }) + it('accounts for every native credits split deduction', () => { + const result = plan([1_030_000n], 4, credits) + expect(result.deduction).toBe(30_000n) + expect(result.steps.map((step) => step.amount)).toEqual([250_000n, 250_000n, 250_000n]) + }) + it('does nothing for already balanced inventory and tolerates atomic rounding', () => { + expect(plan([25n, 25n, 25n, 25n], 4).steps).toEqual([]) + expect(plan([33n, 33n, 34n], 3).steps).toEqual([]) + }) + it('preserves existing records for count-only policies', () => { + const result = plan([10n, 15n, 25n, 50n], 3, arc20, 'preserve') + expect(result.steps).toHaveLength(1) + expect(result.steps[0]?.inputs).toEqual(['0group', '1group']) + }) + it.each([undefined, 'preserve'] as const)('preserves feasible minimum amounts with distribution %s', (distribution) => { + const cases = [ + { asset: arc20, amounts: [9n], records: 3, min: 3n, expected: [3n, 3n, 3n], deduction: 0n }, + { asset: arc20, amounts: [10n, 10n], records: 6, min: 3n, expected: [3n, 3n, 3n, 3n, 4n, 4n], deduction: 0n }, + { asset: arc22, amounts: [3n, 9n], records: 4, min: 3n, expected: [3n, 3n, 3n, 3n], deduction: 0n }, + { asset: credits, amounts: [3_020_000n], records: 3, min: 1_000_000n, + expected: [1_000_000n, 1_000_000n, 1_000_000n], deduction: 20_000n }, + ] + for (const { asset, amounts, records, min, expected, deduction } of cases) { + const result = buildInventoryPlan({ scope, asset, + inputs: amounts.map((amount, index) => ({ id: `${index}group`, amount })), + target: { records, minRecordAmount: min, distribution } }) + // Replay the plan to check the final lots and every intermediate split. + const remaining = new Map(result.inputs.map(record => [record.id, record.amount])) + for (const step of result.steps) { + expect(step.kind).toBe('split') + for (const input of step.inputs) remaining.delete(input) + for (const output of step.outputs) { + expect(output.amount).toBeGreaterThanOrEqual(min) + remaining.set(output.id, output.amount) + } + } + expect(result.steps).toHaveLength(records - amounts.length) + expect([...remaining.values()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0)).toEqual(expected) + expect(result.deduction).toBe(deduction) + // The existing minimum-sized record never needs to be joined or spent. + if (amounts[0] === min) expect(remaining.get('0group')).toBe(min) + } + }) + it('keeps existing halved amounts when they can meet the target', () => { + expect(plan([100n], 3, arc20, 'preserve').steps.map(step => step.amount)).toEqual([50n, 25n]) + expect(plan([100n, 100n], 4, arc20, 'preserve').steps.map(step => step.amount)).toEqual([50n, 50n]) + }) + it('preserves exact balanced lots and repacks when separate small records cannot split', () => { + expect(plan([25n, 25n, 50n], 4).steps).toHaveLength(1) + expect(plan([250_000n, 250_000n, 510_000n], 4, credits).steps).toHaveLength(1) + const result = buildInventoryPlan({ scope, asset: arc20, + inputs: [{ id: '1group', amount: 3n }, { id: '2group', amount: 3n }], + target: { records: 3, minRecordAmount: 2n } }) + expect(result.steps.map((step) => step.kind)).toEqual(['join', 'split', 'split']) + expect(result.steps.at(-1)!.outputs.map((record) => record.amount)).toEqual([2n, 2n]) + }) + it('rejects impossible counts, deductions, overflow, and operation limits', () => { + expect(() => plan([10_001n], 2, credits)).toThrow('infeasible') + expect(() => plan([10n], 0)).toThrow('records') + expect(() => plan([(1n << 128n) - 1n, 1n], 1)).toThrow('overflow') + expect(() => buildInventoryPlan({ scope, asset: arc20, inputs: [{ id: '1group', amount: 100n }], target: { records: 4 }, maxTransactions: 2 })).toThrow('maxTransactions') + }) +}) + +describe('scanner-backed inventory', () => { + it('paginates beyond 1000 records and deduplicates identities', async () => { + const w = world(Array.from({ length: 1001 }, () => 100n)) + const inventory = await w.client.getRecordInventory({ asset: credits }) + expect(inventory.available).toHaveLength(1001) + expect(w.scan).toHaveBeenCalledTimes(2) + }) + it.each([arc20, arc22])('filters compliance and recipient-bound records for $standard', async (asset) => { + const w = world([10n], asset) + w.records.push(makeRecord(20n, 2, asset, 'recipient_bound: true.private, ')) + w.records.push({ ...makeRecord(30n, 3, asset, 'sender: aleo1sender.private, recipient: aleo1owner.private, '), recordName: 'ComplianceRecord' }) + expect((await w.client.getRecordInventory({ asset })).balance).toBe(10n) + }) + it('checks wallet grants before filtering and pins a granted uid when spending', async () => { + const w = world([]) + let records: any[] = [{ programName: credits.program, tag: '1field', uid: 'wallet-record' }] + const request = vi.fn(async ({ method }: any) => { + if (method === 'requestRecords') return records + if (method === 'executeTransaction') return 'at1wallet' + if (method === 'getProgram') throw Object.assign(new Error('Program not found'), { status: 404 }) + throw new Error(method) + }) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any, recordManagement: { enabled: true, store: w.store } }) + await expect(client.getRecordInventory({ asset: credits })).rejects.toThrow('recordName grant') + records = [{ ...records[0], recordName: 'credits', recordView: { fields: { $nonce: '1group', microcredits: '1000000u64' } } }] + const inventory = await client.getRecordInventory({ asset: credits }) + await client.splitRecord({ asset: credits, record: inventory.available[0]!, amount: 100_000n }) + expect(request).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'executeTransaction', + params: expect.objectContaining({ inputs: [{ type: 'record', program: credits.program, recordname: 'credits', uid: 'wallet-record' }, '100000u64'] }) })) + expect((await w.store.list(scope))[0]).toMatchObject({ records: ['1group'], status: 'submitted', transactionId: 'at1wallet' }) + }) + it('refuses missing wallet grants rather than claiming an empty inventory', async () => { + const w = world([10n]) + w.setRecords([{ programName: credits.program, recordName: 'credits', tag: '1field' } as OwnedRecord]) + await expect(w.client.getRecordInventory({ asset: credits })).rejects.toThrow('grant') + }) +}) + +describe('inventory execution and recovery', () => { + it.each([credits, arc20, arc22].flatMap((asset) => ['join', 'split'].map((kind) => ({ asset, kind }))))( + 'refuses RPC $kind for $asset.standard without commitment metadata before submission', async ({ asset, kind }) => { + const w = world([], asset) + const records = (kind === 'join' ? [500_000n, 500_000n] : [1_030_000n]).map((amount, index) => ({ + programName: asset.program, recordName: asset.standard === 'credits' ? 'credits' : 'Token', uid: `wallet-${index}`, + recordView: { fields: { $nonce: `${index + 1}group`, [asset.standard === 'credits' ? 'microcredits' : 'amount']: `${amount}${asset.standard === 'credits' ? 'u64' : 'u128'}` } }, + })) + const request = vi.fn(async (params: any) => params.method === 'requestRecords' ? records : w.send(params)) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any, recordManagement: { enabled: true, store: w.store } }) + const plan = await client.planRecordInventory({ asset, target: { records: kind === 'join' ? 1 : 3 } }) + expect(plan.steps.length).toBeGreaterThan(0) + expect(await client.rebalanceRecordInventory({ plan })).toMatchObject({ + status: 'interrupted', completedSteps: 0, transactionIds: [], reason: expect.stringContaining('$commitment grant'), + }) + expect(request.mock.calls.some(([params]) => params.method === 'executeTransaction')).toBe(false) + expect(await w.store.list(scope)).toEqual([]) + }, + ) + it.each(['commitment', '$commitment'])('completes dependent RPC splits with %s metadata', async (metadata) => { + const w = world([]) + let nonce = 1 + const row = (amount: bigint): any => { + const id = nonce++ + return { programName: credits.program, recordName: 'credits', uid: `wallet-${id}`, + ...(metadata === 'commitment' ? { commitment: `${id}field` } : {}), + recordView: { fields: { $nonce: `${id}group`, microcredits: `${amount}u64`, + ...(metadata === '$commitment' ? { $commitment: `${id}field` } : {}) } } } + } + let records = [row(1_030_000n)] + let transaction: any + let sequence = 0 + const request = vi.fn(async ({ method, params }: any) => { + if (method === 'requestRecords') return records + if (method === 'executeTransaction') { + const input = records.find((record) => record.uid === params.inputs[0].uid) + const amount = BigInt(params.inputs[1].replace(/u64$/, '')) + const outputs = [row(amount), row(BigInt(input.recordView.fields.microcredits.replace(/u64$/, '')) - amount - 10_000n)] + records = records.filter((record) => record !== input).concat(outputs) + transaction = { id: `at1wallet${++sequence}`, type: 'execute', execution: { transitions: [{ + program: credits.program, function: 'split', outputs: outputs.map((record) => ({ + id: record.commitment ?? record.recordView.fields.$commitment, type: 'record', + })), + }] } } + return transaction.id + } + if (method === 'getConfirmedTransaction') return { status: 'accepted', transaction } + if (method === 'getProgram') throw Object.assign(new Error('Program not found'), { status: 404 }) + throw new Error(method) + }) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any, recordManagement: { enabled: true, store: w.store } }) + const plan = await client.planRecordInventory({ asset: credits, target: { records: 3 } }) + expect(await client.rebalanceRecordInventory({ plan, pollIntervalMs: 1 })).toMatchObject({ + status: 'complete', completedSteps: 2, transactionIds: ['at1wallet1', 'at1wallet2'], + }) + expect((await client.planRecordInventory({ asset: credits, target: { records: 3 } })).steps).toEqual([]) + }) + it('preflights later RPC inputs and permits no-op plans without commitment metadata', async () => { + const w = world([]) + const records = [10n, 20n, 30n].map((amount, index) => ({ + programName: credits.program, recordName: 'credits', uid: `wallet-${index}`, + ...(index < 2 ? { commitment: `${index + 1}field` } : {}), + recordView: { fields: { $nonce: `${index + 1}group`, microcredits: `${amount}u64` } }, + })) + const request = vi.fn(async (params: any) => params.method === 'requestRecords' ? records : w.send(params)) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any, recordManagement: { enabled: true, store: w.store } }) + const plan = await client.planRecordInventory({ asset: credits, target: { records: 1 } }) + expect(plan.steps[0]!.inputs).not.toContain('3group') + expect(await client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'interrupted', transactionIds: [] }) + expect(request.mock.calls.some(([params]) => params.method === 'executeTransaction')).toBe(false) + expect(await w.store.list(scope)).toEqual([]) + const noOp = await client.planRecordInventory({ asset: credits, target: { records: 3 } }) + expect(noOp.steps).toEqual([]) + expect(await client.rebalanceRecordInventory({ plan: noOp })).toEqual({ status: 'complete', completedSteps: 0, transactionIds: [] }) + }) + it.each([credits, arc20, arc22])('executes a dependent split plan for $standard and reaches an idempotent target', async (asset) => { + const w = world([1_030_000n], asset) + const target = { records: 4, distribution: 'balanced' as const } + const plan = await w.client.planRecordInventory({ asset, target }) + const result = await w.client.rebalanceRecordInventory({ plan, pollIntervalMs: 1 }) + expect(result).toMatchObject({ status: 'complete', completedSteps: 3 }) + expect((await w.client.planRecordInventory({ asset, target })).steps).toEqual([]) + expect((await w.store.list(scope)).every((entry) => entry.status === 'confirmed')).toBe(true) + }) + it('autojoins only enough records, retaining other inventory', async () => { + const w = world([40n, 30n, 10n], arc20) + const result = await w.client.autoJoin({ asset: arc20, minAmount: 60n }) + expect(result.completedSteps).toBe(1) + expect((await w.client.getRecordInventory({ asset: arc20 })).available.map((record) => record.amount).sort()).toEqual([10n, 70n]) + }) + it('atomically rejects competing spending before the second proof', async () => { + const w = world([1_000_000n]) + const record = (await w.client.getRecordInventory({ asset: credits })).available[0]! + const results = await Promise.allSettled([w.client.splitRecord({ asset: credits, record, amount: 100_000n }), w.client.splitRecord({ asset: credits, record, amount: 200_000n })]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(w.build).toHaveBeenCalledTimes(1) + }) + it('enforces fees before broadcast and releases a build rejected by its budget', async () => { + const w = world([1_000_000n]) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + const result = await w.client.rebalanceRecordInventory({ plan, maxFeeMicrocredits: 99n }) + expect(result.status).toBe('interrupted') + expect(result.reason).toContain('maxFeeMicrocredits') + expect(w.send.mock.calls.some(([request]) => request.method === 'sendTransaction')).toBe(false) + expect((await w.store.list(scope))[0]?.status).toBe('cancelled') + }) + it('retains a prepared transaction when the broadcast response is lost, then reconciles acceptance', async () => { + const w = world([1_000_000n]) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { const result = await original(request); if (request.method === 'sendTransaction') throw new Error('response lost'); return result }) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + const result = await w.client.rebalanceRecordInventory({ plan }) + expect(result).toMatchObject({ status: 'interrupted', transactionIds: ['at1tx1'] }) + expect((await w.store.list(scope))[0]).toMatchObject({ status: 'prepared', transactionId: 'at1tx1' }) + await w.client.reconcileRecordInventory() + expect((await w.store.list(scope))[0]?.status).toBe('confirmed') + }) + it('rebroadcasts the exact saved transaction without rebuilding its proof', async () => { + const w = world([1_000_000n]) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { + if (request.method === 'sendTransaction') throw new Error('offline before acceptance') + return original(request) + }) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + await w.client.rebalanceRecordInventory({ plan }) + const saved = (await w.store.list(scope))[0]! + await w.client.reconcileRecordInventory() // A 404 must retain the reservation. + expect((await w.store.list(scope))[0]?.status).toBe('prepared') + w.send.mockImplementation(original) + await w.client.reconcileRecordInventory({ rebroadcast: true }) + expect(w.send).toHaveBeenLastCalledWith({ method: 'sendTransaction', params: { transaction: JSON.stringify(saved.transaction) } }) + expect(w.build).toHaveBeenCalledTimes(1) + await w.client.reconcileRecordInventory() + expect((await w.store.list(scope))[0]).toMatchObject({ status: 'confirmed' }) + expect((await w.store.list(scope))[0]?.transaction).toBeUndefined() + }) + it('releases inputs only after a confirmed rejection', async () => { + const w = world([1_000_000n]) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { + if (request.method === 'getConfirmedTransaction') return { status: 'rejected' } + if (request.method === 'sendTransaction') return JSON.parse(request.params.transaction).id + return original(request) + }) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + expect((await w.client.rebalanceRecordInventory({ plan })).status).toBe('interrupted') + expect((await w.store.list(scope))[0]?.status).toBe('rejected') + expect((await w.client.getRecordInventory({ asset: credits })).available).toHaveLength(1) + }) + it('does not confuse a new deposit with the expected scanner outputs', async () => { + const w = world([1_000_000n]) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + const original = w.scan.getMockImplementation()! + w.scan.mockImplementation(async (params) => params.filter?.commitments ? [makeRecord(1_000_000n, 9999)] : original(params)) + const result = await w.client.rebalanceRecordInventory({ plan, timeoutMs: 15, pollIntervalMs: 1 }) + expect(result).toMatchObject({ status: 'interrupted', completedSteps: 0 }) + expect(result.reason).toContain('Scanner') + expect(w.build).toHaveBeenCalledTimes(1) + }) + it('retains uncertain inputs on a confirmation outage', async () => { + const w = world([1_000_000n]) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { if (request.method === 'getConfirmedTransaction') throw new Error('network offline'); return original(request) }) + const result = await w.client.rebalanceRecordInventory({ plan }) + expect(result.status).toBe('interrupted') + await expect(w.client.reconcileRecordInventory()).rejects.toThrow('network offline') + expect((await w.store.list(scope))[0]?.status).toBe('submitted') + }) + it('rejects stale inputs, modified plans, cancellation and unsupported private-fee adapters', async () => { + const w = world([1_000_000n]) + const plan = await w.client.planRecordInventory({ asset: credits, target: { records: 2 } }) + const altered = { ...plan, steps: plan.steps.map((step) => ({ ...step, amount: 1n })) } + await expect(w.client.rebalanceRecordInventory({ plan: altered })).rejects.toThrow('modified') + const cancelled = await w.client.rebalanceRecordInventory({ plan, signal: AbortSignal.abort() }) + expect(cancelled.status).toBe('interrupted') + w.setRecords([]) + expect((await w.client.rebalanceRecordInventory({ plan })).reason).toContain('changed') + await expect(w.client.writeContract({ program: credits.program, function: 'join', inputs: [], privateFee: true })).rejects.toThrow('estimateExecutionFee') + expect(w.build).not.toHaveBeenCalled() + }) +}) + +describe('configured token batch joins', () => { + it('pins underlying wallet records and forwards the public identifier and dynamic import', async () => { + const w = world([], arc22, router) + const records = [1, 2, 3].map((nonce) => ({ programName: arc22.program, recordName: 'Token', uid: `wallet-${nonce}`, + recordView: { fields: { $nonce: `${nonce}group`, amount: '100u128' } } })) + const request = vi.fn(async ({ method, params }: any) => { + if (method === 'getProgram') return params.programId === router.program ? routerSource : source(arc22) + if (method === 'requestRecords') return records + if (method === 'executeTransaction') return 'at1walletbatch' + if (method === 'getProgram') throw Object.assign(new Error('Program not found'), { status: 404 }) + throw new Error(method) + }) + const wallet = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any, recordManagement: { enabled: true, store: w.store, tokenJoin: router } }) + await wallet.joinRecords({ asset: arc22, records: (await wallet.getRecordInventory({ asset: arc22 })).available }) + expect(request).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'executeTransaction', params: expect.objectContaining({ + programName: router.program, functionName: 'join_3', imports: [arc22.program], + inputs: ["'test_usdcx_stablecoin'", ...records.map((record) => ({ type: 'record', program: arc22.program, recordname: 'Token', uid: record.uid }))], + }) })) + expect((await w.store.list(scope))[0]).toMatchObject({ records: ['1group', '2group', '3group'], + assetProgram: arc22.program, status: 'submitted', transactionId: 'at1walletbatch' }) + }) + it.each([2, 15, 16, 30])('consolidates %i records in bounded router transactions', async (count) => { + const w = world(Array.from({ length: count }, () => 100n), arc20, router) + const plan = await w.client.planRecordInventory({ asset: arc20, target: { records: 1 }, maxTransactions: 3 }) + expect(plan.tokenJoin).toEqual(router) + expect(plan.steps).toHaveLength(Math.ceil((count - 1) / 14)) + expect(plan.steps.every((step) => step.inputs.length >= 2 && step.inputs.length <= 15)).toBe(true) + const result = await w.client.rebalanceRecordInventory({ plan }) + expect(result).toMatchObject({ status: 'complete', completedSteps: plan.steps.length }) + const firstCount = Math.min(count, 15) + const selected = plan.steps[0]!.inputs.map((id) => w.records.find((record) => parseRecord(record.recordPlaintext).nonce === id)!) + expect(w.build.mock.calls[0]![0]).toMatchObject({ programName: router.program, functionName: `join_${firstCount}`, + imports: [arc20.program], inputs: ["'test_arc20_eth'", ...selected.map((record) => record.recordPlaintext)] }) + expect((await w.client.getRecordInventory({ asset: arc20 })).balance).toBe(BigInt(count) * 100n) + expect((await w.store.list(scope))[0]).toMatchObject({ program: router.program, assetProgram: arc20.program, + function: `join_${firstCount}`, status: 'confirmed', records: plan.steps[0]!.inputs }) + }) + it.each([arc20, arc22])('preserves $standard target lots and executes batch joins followed by native splits', async (asset) => { + const w = world(Array.from({ length: 16 }, () => 100n), asset, router) + const preserve = await w.client.planRecordInventory({ asset, target: { records: 4 } }) + expect(preserve.steps).toHaveLength(1) + expect(preserve.steps[0]?.inputs).toHaveLength(13) + const plan = await w.client.planRecordInventory({ asset, target: { records: 4, distribution: 'balanced' } }) + expect(plan.steps.map((step) => step.kind)).toEqual(['join', 'join', 'split', 'split', 'split']) + expect(await w.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'complete', completedSteps: 5 }) + expect((await w.client.getRecordInventory({ asset })).available.map((record) => record.amount)).toEqual([400n, 400n, 400n, 400n]) + expect((await w.client.planRecordInventory({ asset, target: plan.target })).steps).toEqual([]) + }) + it('retains sufficient-record selection and native credits joins', async () => { + const w = world([40n, 30n, 20n, 10n], arc20, router) + expect(await w.client.autoJoin({ asset: arc20, minAmount: 80n })).toMatchObject({ status: 'complete', completedSteps: 1 }) + expect(w.build.mock.calls[0]![0].functionName).toBe('join_3') + expect((await w.client.getRecordInventory({ asset: arc20 })).available.map((record) => record.amount)).toEqual([10n, 90n]) + const native = world([100n, 100n, 100n], credits, router) + const plan = await native.client.planRecordInventory({ asset: credits, target: { records: 1 } }) + expect(plan.tokenJoin).toBeUndefined() + expect(plan.steps.map((step) => step.inputs.length)).toEqual([2, 2]) + expect(await native.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'complete' }) + expect(native.build.mock.calls.every(([params]) => params.programName === credits.program && params.functionName === 'join')).toBe(true) + }) + it('keeps batch reservations atomic and enforces the proved fee limit', async () => { + const w = world([100n, 100n, 100n], arc20, router) + const records = (await w.client.getRecordInventory({ asset: arc20 })).available + const results = await Promise.allSettled([w.client.joinRecords({ asset: arc20, records }), w.client.joinRecords({ asset: arc20, records: records.slice(0, 2) })]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(w.build).toHaveBeenCalledTimes(1) + const limited = world([100n, 100n, 100n], arc20, router) + const plan = await limited.client.planRecordInventory({ asset: arc20, target: { records: 1 } }) + expect(await limited.client.rebalanceRecordInventory({ plan, maxFeeMicrocredits: 99n })).toMatchObject({ status: 'interrupted', reason: expect.stringContaining('maxFeeMicrocredits') }) + expect(limited.send.mock.calls.some(([request]) => request.method === 'sendTransaction')).toBe(false) + expect((await limited.store.list(scope))[0]).toMatchObject({ status: 'cancelled', records: ['1group', '2group', '3group'] }) + }) + it('rebroadcasts an interrupted batch proof and replans dependent inventory', async () => { + const w = world(Array.from({ length: 16 }, () => 100n), arc22, router) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { if (request.method === 'sendTransaction') throw new Error('offline'); return original(request) }) + const plan = await w.client.planRecordInventory({ asset: arc22, target: { records: 1 } }) + expect(await w.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'interrupted', completedSteps: 0 }) + const saved = (await w.store.list(scope))[0]! + expect(saved.records).toHaveLength(15) + w.send.mockImplementation(original) + await w.client.reconcileRecordInventory({ rebroadcast: true }) + expect(w.send).toHaveBeenLastCalledWith({ method: 'sendTransaction', params: { transaction: JSON.stringify(saved.transaction) } }) + await w.client.reconcileRecordInventory() + expect(await w.client.autoJoin({ asset: arc22 })).toMatchObject({ status: 'complete', completedSteps: 1 }) + expect(w.build).toHaveBeenCalledTimes(2) + expect((await w.client.getRecordInventory({ asset: arc22 })).balance).toBe(1600n) + }) + it('rejects incompatible router code, duplicate inputs, overflow and excessive batch sizes before proving', async () => { + const w = world(Array.from({ length: 16 }, () => 100n), arc20, router) + const records = (await w.client.getRecordInventory({ asset: arc20 })).available + await expect(w.client.joinRecords({ asset: arc20, records })).rejects.toThrow('2 to 15') + await expect(w.client.joinRecords({ asset: arc20, records: [records[0]!, records[0]!] })).rejects.toThrow('distinct') + const huge = world([(1n << 128n) - 1n, 1n], arc20, router) + await expect(huge.client.joinRecords({ asset: arc20, records: (await huge.client.getRecordInventory({ asset: arc20 })).available })).rejects.toThrow('overflows') + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => request.method === 'getProgram' && request.params.programId === router.program + ? routerSource.replace('identifier.public', 'identifier.private') : original(request)) + await expect(w.client.joinRecords({ asset: arc20, records: records.slice(0, 2) })).rejects.toThrow('incompatible') + expect(w.build).not.toHaveBeenCalled() + expect(huge.build).not.toHaveBeenCalled() + }) + it('rejects a different token in the final nested join instead of selecting an intermediate record', async () => { + const w = world([100n, 100n, 100n], arc20, router) + const original = w.send.getMockImplementation()! + w.send.mockImplementation(async (request) => { + const result = await original(request) + if (request.method === 'getConfirmedTransaction') result.transaction.execution.transitions.at(-2).program = 'unrelated.aleo' + return result + }) + const plan = await w.client.planRecordInventory({ asset: arc20, target: { records: 1 } }) + expect(await w.client.rebalanceRecordInventory({ plan })).toMatchObject({ status: 'interrupted', reason: expect.stringContaining('underlying token commitment') }) + expect((await w.store.list(scope))[0]?.status).toBe('confirmed') + expect(w.build).toHaveBeenCalledTimes(1) + }) + it('honors custom batch ceilings and retains native plans created without a router', async () => { + const w = world([100n, 100n, 100n, 100n], arc20, { ...router, maxRecords: 3 }) + const plan = await w.client.planRecordInventory({ asset: arc20, target: { records: 1 } }) + expect(plan.steps.map((step) => step.inputs.length)).toEqual([3, 2]) + expect(() => recordActions({ tokenJoin: { ...router, maxRecords: 16 } })(w.client)).toThrow('2 to 15') + const native = buildInventoryPlan({ scope, asset: arc20, inputs: plan.inputs, target: plan.target }) + expect(await w.client.rebalanceRecordInventory({ plan: native })).toMatchObject({ status: 'complete', completedSteps: 3 }) + expect(w.build.mock.calls.every(([params]) => params.programName === arc20.program)).toBe(true) + }) +}) diff --git a/packages/core/test/inventory/privateFees.test.ts b/packages/core/test/inventory/privateFees.test.ts new file mode 100644 index 00000000..2c549f4f --- /dev/null +++ b/packages/core/test/inventory/privateFees.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWalletClient, memoryRecordInventoryStore, parseRecord, reservedRecordIds, type OwnedRecord } from '../../src/index.js' +import { transactionFee } from '../../src/inventory/spending.js' + +const scope = JSON.stringify(['testnet', 'aleo1owner']) +const record = (nonce: number, amount: bigint): OwnedRecord => ({ + programName: 'credits.aleo', recordName: 'credits', tag: `${nonce}field`, spent: false, + recordPlaintext: `{ owner: aleo1owner.private, microcredits: ${amount}u64.private, _nonce: ${nonce}group.public }`, +}) +function world(records = [record(1, 500n), record(2, 110n), record(3, 200n)]) { + const store = memoryRecordInventoryStore() + let sequence = 0 + let accepted: 'accepted' | 'rejected' = 'accepted' + let failure = false + let wrongFee = false + const prove = async (options: any) => { + if (failure) throw new Error('Proving failed') + const parsed = parseRecord(options.feeRecord) + const selected = records.find(r => parseRecord(r.recordPlaintext).nonce === parsed.nonce)! + return { id: `at1${++sequence}`, type: 'execute', execution: { transitions: [] }, fee: { transition: { + program: 'credits.aleo', function: 'fee_private', inputs: [ + { type: 'record', tag: wrongFee ? '999field' : selected.tag }, { type: 'public', value: '100u64' }, + { type: 'public', value: `${options.fee ?? 0n}u64` }, + ], + } } } + } + const send = vi.fn(async ({ method, params }: any) => { + if (method === 'sendTransaction') return JSON.parse(params.transaction).id + if (method === 'getConfirmedTransaction') return { status: accepted, transaction: { id: params.id } } + throw new Error(method) + }) + const build = vi.fn(prove) + const execute = vi.fn(async (options: any) => { + const tx = await prove(options) + await options.onProgress({ type: 'transaction-prepared', transactionId: tx.id, transaction: tx }) + await send({ method: 'sendTransaction', params: { transaction: JSON.stringify(tx) } }) + await options.onProgress({ type: 'transaction-submitted', transactionId: tx.id }) + return { transactionId: tx.id, transitions: [], outputs: [] } + }) + const client = createWalletClient({ + account: { type: 'local', source: 'fixture', address: 'aleo1owner', privateKey: 'fixture', viewKey: 'fixture', sign: vi.fn(), signMessage: vi.fn() }, + transport: { config: { type: 'custom', key: 'test', name: 'test', network: 'testnet', request: send }, request: send }, + proving: { mode: 'local', estimateExecutionFee: async () => 100n, buildTransaction: build, execute } as any, + recordProvider: { requestRecords: async () => records, setAccount() {} }, + recordManagement: { enabled: true, store, maxFeeMicrocredits: 150n }, + }) + const params = { program: 'credits.aleo', function: 'transfer_private', inputs: [records[0]!.recordPlaintext, 'aleo1recipient', '1u64'], privateFee: true } + return { client, store, params, build, send, records, reject: () => { accepted = 'rejected' }, fail: () => { failure = true }, wrongFee: () => { wrongFee = true } } +} + +describe('managed private fees', () => { + it.each(['writeContract', 'executeContract'] as const)('%s selects and journals a separate fee record including priority fees', async action => { + const w = world() + await w.client[action]({ ...w.params, ...(action === 'executeContract' ? { fee: 20n } : {}) }) + const [entry] = await w.store.list(scope) + expect(entry).toMatchObject({ records: ['1group', action === 'executeContract' ? '3group' : '2group'], + feeRecordId: action === 'executeContract' ? '3group' : '2group', feeMicrocredits: action === 'executeContract' ? '120' : '100', status: 'submitted' }) + expect(JSON.stringify(entry)).not.toContain('microcredits:') + }) + it.each(['writeContract', 'executeContract'] as const)('%s reports input contention separately from private-fee exhaustion', async action => { + const w = world() + await w.store.acquire({ scope, records: ['1group'], program: 'credits.aleo', function: 'join', status: 'prepared', createdAt: 1, accountType: 'local' }) + await expect(w.client[action](w.params)).rejects.toMatchObject({ name: 'RecordReservationConflictError', recordIds: ['1group'] }) + expect(w.build).not.toHaveBeenCalled() + expect(await w.store.list(scope)).toHaveLength(1) + }) + it('does not classify unavailable private fees as an input reservation conflict', async () => { + const w = world([record(1, 500n)]) + const error = await w.client.executeContract(w.params).catch(error => error) + expect(error.message).toContain('private fee') + expect(error.name).not.toBe('RecordReservationConflictError') + expect(await w.store.list(scope)).toHaveLength(0) + }) + it('uses a supplied fee record when it is eligible even if another is smaller', async () => { + const w = world() + await w.client.writeContract({ ...w.params, feeRecord: w.records[2]!.recordPlaintext } as any) + expect((await w.store.list(scope))[0]?.feeRecordId).toBe('3group') + }) + it('replaces a supplied fee record that is already reserved', async () => { + const w = world() + await w.store.acquire({ scope, records: ['3group'], program: 'credits.aleo', function: 'join', status: 'prepared', createdAt: 1, accountType: 'local' }) + await w.client.writeContract({ ...w.params, feeRecord: w.records[2]!.recordPlaintext } as any) + expect((await w.store.list(scope))[1]?.feeRecordId).toBe('2group') + }) + it('does not reuse a supplied fee record that is an execution input', async () => { + const w = world() + await w.client.writeContract({ ...w.params, feeRecord: w.records[0]!.recordPlaintext } as any) + expect((await w.store.list(scope))[0]?.feeRecordId).toBe('2group') + }) + it('chooses disjoint fee records when independent writes race', async () => { + const w = world([record(1, 100n), record(2, 100n)]) + await Promise.all([1, 2].map(() => w.client.writeContract({ ...w.params, inputs: ['aleo1recipient', '1u64'] }))) + const entries = await w.store.list(scope) + expect(new Set(entries.map(e => e.feeRecordId))).toEqual(new Set(['1group', '2group'])) + }) + it('excludes reserved records and fails before proving if no separate record covers the fee', async () => { + const w = world([record(1, 500n), record(2, 100n), record(3, 99n)]) + await w.store.acquire({ scope, records: ['2group'], program: 'credits.aleo', function: 'join', status: 'prepared', createdAt: 1, accountType: 'local' }) + await expect(w.client.writeContract(w.params)).rejects.toThrow('private fee') + expect(w.build).not.toHaveBeenCalled() + expect(await w.store.list(scope)).toHaveLength(1) + }) + it('releases both inputs when proving fails before a prepared transaction exists', async () => { + const w = world(); w.fail() + await expect(w.client.writeContract(w.params)).rejects.toThrow('Proving failed') + expect((await w.store.list(scope))[0]).toMatchObject({ records: ['1group', '2group'], status: 'cancelled' }) + expect(await reservedRecordIds(w.client)).toEqual(new Set()) + }) + it('rejects a prover that spends a different fee record before broadcast', async () => { + const w = world(); w.wrongFee() + await expect(w.client.writeContract(w.params)).rejects.toThrow('reserved private fee record') + expect(w.send).not.toHaveBeenCalled() + expect((await w.store.list(scope))[0]?.status).toBe('cancelled') + }) + it('retains the spent private fee after a rejected execution while releasing its other inputs', async () => { + const w = world() + await w.client.writeContract(w.params); w.reject() + await w.client.reconcileRecordInventory() + expect(await reservedRecordIds(w.client)).toEqual(new Set(['2group'])) + const entry = { scope, program: 'credits.aleo', function: 'join', status: 'reserved' as const, createdAt: 1, accountType: 'local' as const } + expect(await w.store.acquire({ ...entry, records: ['2group'] })).toBeUndefined() + expect(await w.store.acquire({ ...entry, records: ['1group'] })).toBeDefined() + }) + it('preserves both reservations when broadcast outcome is unknown', async () => { + const w = world() + w.send.mockRejectedValue(new Error('Connection lost')) + await expect(w.client.writeContract(w.params)).rejects.toThrow('Connection lost') + expect((await w.store.list(scope))[0]).toMatchObject({ status: 'prepared', records: ['1group', '2group'] }) + }) + it('reads the public amounts after the record input in fee_private', () => { + expect(transactionFee({ fee: { transition: { function: 'fee_private', inputs: [ + { type: 'record', tag: '1field' }, { value: '100u64' }, { value: '20u64' }, + ] } } } as any)).toBe(120n) + }) +}) diff --git a/packages/core/test/inventory/recovery.test.ts b/packages/core/test/inventory/recovery.test.ts new file mode 100644 index 00000000..25a2c1bb --- /dev/null +++ b/packages/core/test/inventory/recovery.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWalletClient, memoryRecordInventoryStore, reservedRecordIds, TransportError } from '../../src/index.js' + +const scope = JSON.stringify(['testnet', 'aleo1owner']) + +function fixture() { + const store = memoryRecordInventoryStore() + const confirmations = new Map() + const broadcast = vi.fn(async (transaction: string) => JSON.parse(transaction).id as string) + const request = vi.fn(async ({ method, params }: any) => { + if (method === 'sendTransaction') return broadcast(params.transaction) + if (method === 'getConfirmedTransaction') { + const status = confirmations.get(params.id) + if (status) return { status, transaction: { id: params.id } } + throw new TransportError('Transaction not found', { status: 404 }) + } + throw new Error('Unexpected request: ' + method) + }) + const client = createWalletClient({ + account: { type: 'local', address: 'aleo1owner', privateKey: 'test', viewKey: 'test' } as any, + transport: { config: { type: 'custom', key: 'test', name: 'Test', network: 'testnet', request }, request }, + recordManagement: { enabled: true, store }, + }) + const save = async (index: number, feeRecordId?: string) => { + const transactionId = 'at1saved' + index + return (await store.acquire({ scope, program: 'token.aleo', function: 'join', + records: [index + 'group', ...(feeRecordId ? [feeRecordId] : [])], feeRecordId, + status: 'prepared', accountType: 'local', createdAt: Date.now(), transactionId, + transaction: { id: transactionId, type: 'execute' } as any }))! + } + return { client, store, confirmations, broadcast, request, save } +} + +describe('inventory rebroadcast diagnostics', () => { + it.each([new TransportError('Serial number already spent'), new Error('Network unavailable'), 'Broadcast denied'])( + 'retains failed spends and reconciles later entries after %s', async error => { + const f = fixture() + const failed = await f.save(1, 'fee1group') + const confirmed = await f.save(2) + f.confirmations.set(confirmed.transactionId!, 'accepted') + f.broadcast.mockRejectedValue(error) + for (const attempts of [1, 2]) { + const journal = await f.client.reconcileRecordInventory({ rebroadcast: true }) + expect(journal[0]).toMatchObject({ status: 'prepared', transactionId: failed.transactionId, + transaction: failed.transaction, records: failed.records, feeRecordId: 'fee1group', + rebroadcastAttempts: attempts, lastRebroadcastError: error instanceof Error ? error.message : error }) + expect(journal[1]).toMatchObject({ status: 'confirmed' }) + expect(journal[1]!.transaction).toBeUndefined() + expect(await reservedRecordIds(f.client)).toEqual(new Set([...failed.records, ...confirmed.records])) + } + expect(f.broadcast.mock.calls.map(([transaction]) => JSON.parse(transaction))).toEqual([failed.transaction, failed.transaction]) + }, + ) + + it('clears the last error when the same saved transaction broadcasts successfully', async () => { + const f = fixture() + const saved = await f.save(1) + f.broadcast.mockRejectedValueOnce(new Error('Network unavailable')) + await f.client.reconcileRecordInventory({ rebroadcast: true }) + const journal = await f.client.reconcileRecordInventory({ rebroadcast: true }) + expect(journal[0]).toMatchObject({ status: 'submitted', rebroadcastAttempts: 2, transaction: saved.transaction }) + expect(journal[0]!.lastRebroadcastError).toBeUndefined() + expect(f.broadcast.mock.calls.map(([transaction]) => JSON.parse(transaction))).toEqual([saved.transaction, saved.transaction]) + }) + + it.each(['accepted', 'rejected'] as const)('clears stale errors after confirmed %s without rebroadcasting', async status => { + const f = fixture() + const saved = await f.save(1, 'fee1group') + f.broadcast.mockRejectedValueOnce(new Error('Network unavailable')) + await f.client.reconcileRecordInventory({ rebroadcast: true }) + f.confirmations.set(saved.transactionId!, status) + const [entry] = await f.client.reconcileRecordInventory({ rebroadcast: true }) + expect(entry).toMatchObject({ status: status === 'accepted' ? 'confirmed' : 'rejected', rebroadcastAttempts: 1 }) + expect(entry!.transaction).toBeUndefined() + expect(entry!.lastRebroadcastError).toBeUndefined() + expect(f.broadcast).toHaveBeenCalledTimes(1) + expect(await reservedRecordIds(f.client)).toEqual(new Set(status === 'accepted' ? saved.records : ['fee1group'])) + }) + + it('records a mismatched broadcast id without overwriting the original proof or unlocking inputs', async () => { + const f = fixture() + const saved = await f.save(1) + const later = await f.save(2) + f.confirmations.set(later.transactionId!, 'accepted') + f.broadcast.mockResolvedValue('at1different') + const journal = await f.client.reconcileRecordInventory({ rebroadcast: true }) + expect(journal[0]).toMatchObject({ status: 'prepared', transactionId: saved.transactionId, transaction: saved.transaction, + rebroadcastAttempts: 1, lastRebroadcastError: 'Rebroadcast returned a different transaction id' }) + expect(journal[1]!.status).toBe('confirmed') + expect(await reservedRecordIds(f.client)).toContain(saved.records[0]) + }) + + it('propagates a confirmation checkpoint storage 404 without rebroadcasting', async () => { + const f = fixture() + const saved = await f.save(1) + f.confirmations.set(saved.transactionId!, 'accepted') + vi.spyOn(f.store, 'update').mockRejectedValue(new TransportError('Journal entry missing', { status: 404 })) + await expect(f.client.reconcileRecordInventory({ rebroadcast: true })).rejects.toThrow('Journal entry missing') + expect(f.broadcast).not.toHaveBeenCalled() + }) + + it.each([true, false])('propagates storage errors when broadcast fails: %s', async fails => { + const f = fixture() + await f.save(1) + if (fails) f.broadcast.mockRejectedValue(new Error('Network unavailable')) + const update = vi.spyOn(f.store, 'update').mockRejectedValue(new Error('Journal unavailable')) + await expect(f.client.reconcileRecordInventory({ rebroadcast: true })).rejects.toThrow('Journal unavailable') + expect(update).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index c9351951..873752f8 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -160,3 +160,50 @@ const proof = await sdk.generateFreezelistProof({ address, tree, root }) The resulting string can be passed to an ARC-22 transfer or bridge proof input. SDK-created proving configs expose the same generator to Shield Swap, which resolves and caches witnesses automatically for local and delegated proving. + +## Record management + +`createAleoClient` returns a wallet client with record inventory methods. +Enable `recordManagement` to reserve inputs during writes and retain transaction +progress. The SDK supplies the scanner and proving; the application chooses +where to store the reservation journal. + +```ts +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import type { RecordInventoryStore } from '@provablehq/veil-core' + +async function previewInventory(privateKey: string, store: RecordInventoryStore) { + const aleo = await loadNetwork('testnet') + const { walletClient: client } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + }) + const plan = await client.planRecordInventory({ + asset: { program: 'credits.aleo' }, + target: { records: 6, minRecordAmount: 1_000_000n, distribution: 'balanced' }, + maxTransactions: 24, + }) + return { client, plan } // Inspect the plan before executing it. +} +// After review: await client.rebalanceRecordInventory({ plan }) +``` + +Omitting `recordManagement` or setting `enabled: false` leaves coordination off. +An enabled client without `store` creates its own in-memory journal. Share one +store and chain identity across clients using the same account, and use durable +storage to recover after a restart. `createDevnodeClient` accepts the same +configuration; give each local chain a distinct `chainId`. + +`waitForSync: true` waits for scanner synchronization before each page +(default `false`, with a 300,000 ms sync timeout). It does not reserve records; +reservations are acquired when the client executes a write. Local and delegated +proving both save the prepared transaction before broadcasting when management +is enabled. Managed local writes can use `privateFee: true` and an optional +preferred `feeRecord`; the client selects another credits record if needed. +Explicit private fees take precedence over FeeMaster for that call. + +See the [record management guide](../../site/docs/guides/record-inventory.md) +for memory and SQLite setup, configuration defaults, fees, joins/splits, and +recovery. The [concurrent swaps example](../../examples/shield-swap/concurrent-swaps.ts) +composes this client with Shield Swap and persists both reservation and claim data. diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index 4beb030a..daf178a7 100644 --- a/packages/provable-sdk/src/index.ts +++ b/packages/provable-sdk/src/index.ts @@ -33,7 +33,7 @@ import { import { DEVNODE_PRIVATE_KEY, DEVNODE_ADDR } from '@provablehq/veil-aleo-devnode' export { DEVNODE_PRIVATE_KEY, DEVNODE_ADDR } import type { LocalAccount } from '@provablehq/veil-core' -import type { ProvingConfig, GenerateFreezelistProofParameters, BuildTransactionOptions, BuildDeploymentOptions, SimulateOptions, ExecuteOptions, RawSimulateResult, RawExecuteResult, Transaction } from '@provablehq/veil-core' +import type { RecordManagementConfig, ProvingConfig, EstimateExecutionFeeOptions, GenerateFreezelistProofParameters, BuildTransactionOptions, BuildDeploymentOptions, SimulateOptions, ExecuteOptions, RawSimulateResult, RawExecuteResult, Transaction } from '@provablehq/veil-core' import { generateFreezelistProof } from './freezelist.js' import type { OwnedRecord, RecordProvider, StandaloneRecordScanner, RequestRecordsParameters } from '@provablehq/veil-core' import type { Network, PublicClient, WalletClient } from '@provablehq/veil-core' @@ -324,6 +324,8 @@ export interface AleoSdk { * shared with delegated proving. `createAleoClient` supplies its own * session through `setSession` on the returned provider, so a caller who * passes the scanner to that factory does not need this. + * @param options.waitForSync Waits for scanner synchronization before each page. Defaults to false. + * @param options.syncTimeoutMs Synchronization timeout in milliseconds. Defaults to 300000. * @param options.startBlock Optional block height to begin scanning from at * registration. Defaults to 0 (full history). * @param options.auth Optional provisioned-key auth for the edge gateway. @@ -336,6 +338,8 @@ export interface AleoSdk { * can tell whether a legacy gateway is in play. */ createRemoteScanner(options?: { + waitForSync?: boolean + syncTimeoutMs?: number url?: string consumerId?: string apiKey?: string @@ -406,6 +410,9 @@ export interface AleoSdk { * to true, so a faucet-funded account with no public credits can write. * Pass false when the account funds its own fees. Only meaningful under * delegated proving. + * @param options.recordManagement Optional shared record reservations and recovery. + * Defaults to disabled. Set enabled: true to reserve inputs during writes + * and track unfinished transactions. * @param options.records Record provider behind `requestRecords`. Defaults * to `createRemoteScanner()` against the hosted scanner; pass a scanner * built with a custom `url` or any `RecordProvider` to override. @@ -447,6 +454,7 @@ export interface AleoSdk { * const txId = await walletClient.writeContract({ program, function: 'transfer_public', inputs }) */ createAleoClient(options: { + recordManagement?: RecordManagementConfig privateKey: string networkUrl?: string provingMode?: 'delegated' | 'local' @@ -507,6 +515,31 @@ function silenceSdkLogs(sdk: SdkModule): void { setLogLevel?.('silent') } +/** + * Estimates an execution's base fee after resolving dynamic program imports. + * Reads program sources through the configured SDK network client without proving or submitting. + * @param manager SDK manager bound to the selected network. + * @param params Function and optional sources or dynamic import names. + * @returns Base fee in microcredits, excluding the caller's priority fee. + * @throws When program discovery or fee estimation fails. + * @example + * const fee = await estimateBaseFee(manager, { programName: 'credits.aleo', functionName: 'join' }) + */ +async function estimateBaseFee(manager: InstanceType, params: EstimateExecutionFeeOptions): Promise { + let imports: Awaited> | undefined + if (Array.isArray(params.imports)) { + imports = {} + // Include each dynamic target and its static dependencies in the estimate, + // matching the sources later handed to execution or delegated proving. + for (const name of params.imports) { + const source = await manager.networkClient.getProgram(name) + Object.assign(imports, await manager.networkClient.getProgramImports(source), { [name]: source }) + } + } else imports = params.imports + return manager.estimateExecutionFee({ programName: params.programName, functionName: params.functionName, + program: params.programSource, imports }) +} + function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): AleoSdk { // Mutable handle so `createProvingConfig().switchNetwork()` can swap the // underlying binary set without rebuilding the wallet client. @@ -647,6 +680,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo keyedAuth: options.auth, generateFreezelistProof: (params) => generateFreezelistProof(currentSdk, params), + estimateExecutionFee: (params) => estimateBaseFee(new currentSdk.ProgramManager(networkUrl, keyProvider, undefined), params), + buildTransaction: async (txOptions: BuildTransactionOptions) => { const programManager = new currentSdk.ProgramManager( networkUrl, @@ -673,8 +708,12 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo const programSource = await programManager.networkClient.getProgram(txOptions.programName) const staticImports = await programManager.networkClient.getProgramImports(programSource) const merged: SdkProgramImports = { ...staticImports } + // Include the token program's own imports as well as its source; + // routed joins need both to produce a proof. for (const name of txOptions.imports) { - merged[name] = await programManager.networkClient.getProgram(name) + const dynamicSource = await programManager.networkClient.getProgram(name) + Object.assign(merged, await programManager.networkClient.getProgramImports(dynamicSource)) + merged[name] = dynamicSource } resolvedImports = merged } @@ -693,10 +732,11 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo functionName: txOptions.functionName, priorityFee: 0, privateFee: txOptions.privateFee ?? false, + ...(txOptions.feeRecord ? { feeRecord: txOptions.feeRecord } : {}), inputs: txOptions.inputs, ...(resolvedImports ? { programImports: resolvedImports } : {}), broadcast: false, - useFeeMaster: options.useFeeMaster ?? false, + useFeeMaster: txOptions.feeRecord ? false : options.useFeeMaster ?? false, }) await txOptions.onProgress?.({ type: 'request-built' }) @@ -750,6 +790,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo functionName: txOptions.functionName, priorityFee: 0, privateFee: txOptions.privateFee ?? false, + ...(txOptions.feeRecord ? { feeRecord: txOptions.feeRecord } : {}), inputs: txOptions.inputs, ...(resolvedImports ? { imports: resolvedImports } : {}), }) @@ -866,8 +907,11 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? false, - broadcast: true, - useFeeMaster: options.useFeeMaster ?? false, + ...(execOptions.feeRecord ? { feeRecord: execOptions.feeRecord } : {}), + // Submit from this client when a progress callback is present, + // so it can save the proved transaction before broadcast. + broadcast: !execOptions.onProgress, + useFeeMaster: execOptions.feeRecord ? false : options.useFeeMaster ?? false, }) const dpsClient = new AleoNetworkClient(proverUrl) @@ -913,7 +957,14 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo const txId = response.transaction?.id if (!txId) throw new ConfigurationError('DPS response did not contain a transaction ID — check prover service configuration.') - const broadcastResult = response.broadcast_result + // A journalled execution must return the proved transaction before any + // broadcast, including delegated proving. The awaited callback persists it. + if (execOptions.onProgress) { + await execOptions.onProgress({ type: 'transaction-prepared', transactionId: txId, transaction: response.transaction }) + const submitted = await buildPollingClient().request({ method: 'sendTransaction', params: { transaction: JSON.stringify(response.transaction) } }) + if (submitted !== txId) throw new ConfigurationError('Broadcast returned an unexpected transaction id') + } + const broadcastResult = execOptions.onProgress ? { status: 'Accepted' } : response.broadcast_result if (!broadcastResult || broadcastResult.status !== 'Accepted') { const message = broadcastResult?.status === 'Skipped' ? 'Delegated prover skipped transaction broadcast' @@ -925,7 +976,9 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw classifyBroadcastError(error, txId) } + await execOptions.onProgress?.({ type: 'transaction-submitted', transactionId: txId }) const confirmedTx = await waitForConfirmation(buildPollingClient(), txId, options.confirmationTimeout) + await execOptions.onProgress?.({ type: 'transaction-confirmed', transactionId: txId }) const { transitions, outputs } = extractTransitions(confirmedTx, decryptor) return { transactionId: txId, transitions, outputs } @@ -938,6 +991,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? false, + ...(execOptions.feeRecord ? { feeRecord: execOptions.feeRecord } : {}), program: execOptions.programSource, imports: execOptions.programImports, }) @@ -946,6 +1000,10 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw new ProvingError({ message: e instanceof Error ? e.message : String(e), cause: e as Error }) } + // Serialize the proved transaction so the callback can save it for + // recovery before it is submitted to the network. + const prepared = JSON.parse(tx.toString()) as Transaction + await execOptions.onProgress?.({ type: 'transaction-prepared', transactionId: prepared.id, transaction: prepared }) let txId: string try { const submitClient = new AleoNetworkClient(networkUrl) @@ -956,7 +1014,9 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw classifyBroadcastError(e) } + await execOptions.onProgress?.({ type: 'transaction-submitted', transactionId: txId }) const confirmedTx = await waitForConfirmation(buildPollingClient(), txId, options.confirmationTimeout) + await execOptions.onProgress?.({ type: 'transaction-confirmed', transactionId: txId }) const { transitions, outputs } = extractTransitions(confirmedTx, decryptor) return { transactionId: txId, transitions, outputs } } @@ -1128,6 +1188,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } function createRemoteScanner(options: { + waitForSync?: boolean + syncTimeoutMs?: number url?: string consumerId?: string apiKey?: string @@ -1139,6 +1201,9 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo setSession: (session: ProvableSession) => void setAuth: (auth: ProvableKeyedAuth) => void } { + if (options.syncTimeoutMs !== undefined && (!Number.isSafeInteger(options.syncTimeoutMs) || options.syncTimeoutMs < 1)) { + throw new ConfigurationError('syncTimeoutMs must be a positive integer in milliseconds') + } assertKeyedAuthAlone(options.auth, { session: options.session, apiKey: options.apiKey, @@ -1254,6 +1319,27 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo // rides in the scanner, so there is no token to apply. if (activeSession) activeScanner.setJwtData(await activeSession.getJwt()) await activeRegistration.ensure(activeScanner, activeViewKey) + if (options.waitForSync) { + // An incomplete scan could trigger needless joins or splits. Wait for + // the existing scanner registration to catch up before reading a page. + const deadline = Date.now() + (options.syncTimeoutMs ?? 300_000) + let reregistered = false + while (true) { + const status = await activeScanner.status() + // status() does not use owned()'s registration recovery. Retry one + // expired registration explicitly, then surface any further failure. + if (!status.ok && status.status === 422 && !reregistered) { + const registration = await activeScanner.registerEncrypted(activeViewKey, options.startBlock ?? 0) + if (!registration.ok) throw new Error(`Record scanner registration failed (HTTP ${registration.status})`) + reregistered = true + continue + } + if (!status.ok) throw new Error(`Record scanner status failed (HTTP ${status.status})`) + if (status.data.synced) break + if (Date.now() >= deadline) throw new Error('Record scanner synchronization timed out') + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } return scanOwned(activeScanner, params, activeSession) }, @@ -1325,6 +1411,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } function createAleoClient(options: { + recordManagement?: RecordManagementConfig privateKey: string networkUrl?: string provingMode?: 'delegated' | 'local' @@ -1431,6 +1518,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo transport, proving, recordProvider: records, + // Pass record settings when creating the client so every wallet action uses them. + recordManagement: options.recordManagement, }).extend(provableApiActions()) return { publicClient, walletClient, account } @@ -1514,6 +1603,8 @@ export function generateAccount(): LocalAccount<'privateKey'> { * pending. Record outputs owned by the client's account are decrypted to * plaintext in the result; foreign records are dropped. * + * @param options.recordManagement Optional shared record reservations and recovery. + * Defaults to disabled; local chains should supply a distinct chainId when enabled. * @example * ```ts * // Zero-config — uses seeded key and localhost:3030 @@ -1527,6 +1618,7 @@ export function generateAccount(): LocalAccount<'privateKey'> { * ``` */ export function createDevnodeClient(options?: { + recordManagement?: RecordManagementConfig privateKey?: string /** Socket address of the devnode, e.g. "127.0.0.1:3030" */ socketAddr?: string @@ -1558,6 +1650,7 @@ export function createDevnodeClient(options?: { functionName: string inputs: string[] privateFee?: boolean | undefined + feeRecord?: string | undefined imports?: Record | undefined }) => { const programManager = new ProgramManager(url, keyProvider, undefined) @@ -1567,6 +1660,7 @@ export function createDevnodeClient(options?: { functionName: opts.functionName, priorityFee: 0, privateFee: opts.privateFee ?? false, + ...(opts.feeRecord ? { feeRecord: opts.feeRecord } : {}), inputs: opts.inputs, ...(opts.imports ? { imports: opts.imports } : {}), }) @@ -1574,6 +1668,7 @@ export function createDevnodeClient(options?: { const proving: ProvingConfig = { mode: 'devnode', + estimateExecutionFee: (params) => estimateBaseFee(new ProgramManager(url, keyProvider, undefined), params), generateFreezelistProof: async (params) => generateFreezelistProof(await loadSdk('testnet'), params), buildDeployment: async (deployOptions: BuildDeploymentOptions) => { const programManager = new ProgramManager(url, keyProvider, undefined) @@ -1614,6 +1709,7 @@ export function createDevnodeClient(options?: { functionName: txOptions.functionName, inputs: txOptions.inputs, privateFee: txOptions.privateFee, + ...(txOptions.feeRecord ? { feeRecord: txOptions.feeRecord } : {}), imports, }) return JSON.parse(tx.toString()) @@ -1624,19 +1720,29 @@ export function createDevnodeClient(options?: { functionName: execOptions.functionName, inputs: execOptions.inputs, privateFee: execOptions.privateFee, + ...(execOptions.feeRecord ? { feeRecord: execOptions.feeRecord } : {}), imports: execOptions.programImports, }) + // Save the proved transaction through the progress callback before + // broadcasting to the devnode, as with local and delegated proving. + const prepared = JSON.parse(tx.toString()) as Transaction + await execOptions.onProgress?.({ type: 'transaction-prepared', transactionId: prepared.id, transaction: prepared }) + // Broadcast through the same transport-backed request writeContract uses. const txId = (await publicClient.request({ method: 'sendTransaction', params: { transaction: tx.toString() }, })) as string + await execOptions.onProgress?.({ type: 'transaction-submitted', transactionId: txId }) + // The devnode auto-produces a block after broadcast by default; under // manualBlockCreation the caller must advance blocks for this to resolve. const confirmedTx = await waitForConfirmation(publicClient, txId) + await execOptions.onProgress?.({ type: 'transaction-confirmed', transactionId: txId }) + // Self-custody decryptor: records owned by the devnode account surface // as plaintext; records owned by someone else (e.g. a compliance record // minted to an authority) pass through as ciphertext rather than being @@ -1652,7 +1758,7 @@ export function createDevnodeClient(options?: { }, } - const walletClient = createWalletClient({ account, transport, proving }) + const walletClient = createWalletClient({ account, transport, proving, recordManagement: options?.recordManagement }) return { publicClient, walletClient, account } } diff --git a/packages/provable-sdk/test/delegatedBuild.test.ts b/packages/provable-sdk/test/delegatedBuild.test.ts index 5d8f0bc9..9127775c 100644 --- a/packages/provable-sdk/test/delegatedBuild.test.ts +++ b/packages/provable-sdk/test/delegatedBuild.test.ts @@ -11,6 +11,26 @@ describe('delegated transaction building', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('includes the dynamic token and its transitive imports when proving a router join', async () => { + const router = 'program test_aj_arc20_2_15.aleo;' + const token = 'import helper.aleo; program test_usdcx_stablecoin.aleo;' + const helper = 'program helper.aleo;' + vi.spyOn(testnetSdk.AleoNetworkClient.prototype, 'getProgram').mockImplementation(async (name) => + name === 'test_aj_arc20_2_15.aleo' ? router : token) + vi.spyOn(testnetSdk.AleoNetworkClient.prototype, 'getProgramImports').mockImplementation(async (source) => + source === token ? { 'helper.aleo': helper } : {}) + const request = vi.spyOn(testnetSdk.ProgramManager.prototype, 'provingRequest').mockResolvedValue({ encrypted: true } as never) + vi.spyOn(testnetSdk.AleoNetworkClient.prototype, 'submitProvingRequestSafe').mockResolvedValue({ ok: true, + data: { transaction: { id: 'at1batch' }, broadcast_result: { status: 'Skipped' } } } as never) + const config = aleo.createProvingConfig({ mode: 'delegated', networkUrl: 'https://node.example', + proverUrl: 'https://prover.example', account: aleo.generateAccount() }) + await config.buildTransaction!({ programName: 'test_aj_arc20_2_15.aleo', functionName: 'join_3', + inputs: ["'test_usdcx_stablecoin'", 'record-a', 'record-b', 'record-c'], imports: ['test_usdcx_stablecoin.aleo'] }) + expect(request).toHaveBeenCalledWith(expect.objectContaining({ broadcast: false, + programImports: { 'test_usdcx_stablecoin.aleo': token, 'helper.aleo': helper } })) }) it('asks the prover not to broadcast and reports each proving boundary', async () => { @@ -49,6 +69,25 @@ describe('delegated transaction building', () => { ]) }) + it('awaits the durable checkpoint and never broadcasts when it fails', async () => { + const transaction = { type: 'execute', id: 'at1prepared', fee: {} } + const request = vi.spyOn(testnetSdk.ProgramManager.prototype, 'provingRequest') + .mockResolvedValue({ encrypted: true } as never) + vi.spyOn(testnetSdk.AleoNetworkClient.prototype, 'submitProvingRequestSafe') + .mockResolvedValue({ ok: true, data: { transaction, broadcast_result: { status: 'Skipped' } } } as never) + const fetch = vi.fn(() => { throw new Error('must not broadcast') }) + vi.stubGlobal('fetch', fetch) + const config = aleo.createProvingConfig({ mode: 'delegated', networkUrl: 'https://node.example', + proverUrl: 'https://prover.example', account: aleo.generateAccount() }) + const events: unknown[] = [] + await expect(config.execute!({ programName: 'credits.aleo', functionName: 'join', inputs: [], fee: 0n, + async onProgress(event) { events.push(event); throw new Error('disk full') }, + })).rejects.toThrow('disk full') + expect(request).toHaveBeenCalledWith(expect.objectContaining({ broadcast: false })) + expect(events).toEqual([{ type: 'transaction-prepared', transactionId: transaction.id, transaction }]) + expect(fetch).not.toHaveBeenCalled() + }) + it('reports a failed delegated broadcast without polling for confirmation', async () => { const transaction = { type: 'execute', id: 'at1notbroadcast', fee: {} } vi.spyOn(testnetSdk.ProgramManager.prototype, 'provingRequest') diff --git a/packages/provable-sdk/test/integration/privateFees.e2e.test.ts b/packages/provable-sdk/test/integration/privateFees.e2e.test.ts new file mode 100644 index 00000000..ee7c9f12 --- /dev/null +++ b/packages/provable-sdk/test/integration/privateFees.e2e.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createServer } from 'node:net' +import { GraphKey, ViewKey, RecordPlaintext, Field } from '@provablehq/sdk/testnet.js' +import { startDevnode, type DevnodeInstance } from '@provablehq/veil-aleo-devnode' +import { createDevnodeClient } from '../../src/index.js' +import { createWalletClient, createTestClient, http, memoryRecordInventoryStore, parseRecord, waitForConfirmation, type OwnedRecord, type WalletClient } from '@provablehq/veil-core' + +const RUN = process.env.VEIL_DEVNODE_INTEGRATION === '1' + +// Uses an isolated local ledger and its seeded account. The provider deliberately +// keeps stale records so reservation checks must prevent their reuse after fees. +describe.runIf(RUN)('devnode: managed private fees', () => { + let node: DevnodeInstance | undefined + let client: WalletClient + const records: OwnedRecord[] = [] + const store = memoryRecordInventoryStore() + let scope: string + let firstFee: string + + beforeAll(async () => { + // Allocate an unused port instead of interrupting another local devnode. + const server = createServer() + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + const socketAddr = `127.0.0.1:${port}` + node = await startDevnode({ socketAddr, readyTimeout: 45_000 }) + const initial = createDevnodeClient({ socketAddr }) + const transport = http(`http://${socketAddr}`, { network: 'testnet' }) + await createTestClient({ transport }).advanceBlock({ count: 1 }) + const graphKey = GraphKey.from_view_key(ViewKey.from_string(initial.account.viewKey)) + // Create five distinct credits records with public fees before enabling + // coordination. Every later fee must come from this discovered set. + for (let i = 0; i < 5; i++) { + const result = await initial.walletClient.executeContract({ program: 'credits.aleo', function: 'transfer_public_to_private', + inputs: [initial.account.address, '2000000u64'] }) + const tx = await initial.publicClient.getTransaction({ id: result.transactionId }) + const commitment = tx.execution!.transitions.at(-1)!.outputs!.find(output => output.type === 'record')!.id + const plaintext = result.outputs.find(output => output.startsWith('{'))! + records.push({ programName: 'credits.aleo', recordName: 'credits', recordPlaintext: plaintext, + commitment, tag: RecordPlaintext.fromString(plaintext).tag(graphKey, Field.fromString(commitment)).toString(), spent: false }) + } + const proving = initial.walletClient.proving + if (!proving) throw new Error('Devnode client did not install its prover') + client = createWalletClient({ account: initial.account, transport, proving, + recordProvider: { requestRecords: async () => records, setAccount() {} }, + recordManagement: { enabled: true, store, chainId: 'private-fee-e2e', maxFeeMicrocredits: 1_000_000n } }) + scope = JSON.stringify(['private-fee-e2e', initial.account.address]) + }, 180_000) + afterAll(async () => { await node?.stop() }, 30_000) + + it('executes a private transfer with a separate reserved fee record', async () => { + const result = await client.executeContract({ program: 'credits.aleo', function: 'transfer_private', + inputs: [records[0]!.recordPlaintext, client.account!.address, '100u64'], privateFee: true, feeRecord: records[0]!.recordPlaintext }) + const [entry] = await store.list(scope) + firstFee = entry!.feeRecordId! + expect(entry!.status).toBe('confirmed') + expect(firstFee).not.toBe(parseRecord(records[0]!.recordPlaintext).nonce) + expect(entry!.records).toHaveLength(2) + expect(BigInt(entry!.feeMicrocredits!)).toBeGreaterThan(0n) + const tx = await client.request({ method: 'getTransaction', params: { id: result.transactionId } }) as any + expect(tx.fee.transition.function).toBe('fee_private') + const selected = records.find(record => parseRecord(record.recordPlaintext).nonce === firstFee)! + expect(tx.fee.transition.inputs[0].tag).toBe(selected.tag) + }, 120_000) + + it('replaces a supplied fee record that the previous transaction consumed', async () => { + const prior = records.find(record => parseRecord(record.recordPlaintext).nonce === firstFee)! + const id = await client.writeContract({ program: 'credits.aleo', function: 'transfer_public', + inputs: [client.account!.address, '1u64'], privateFee: true, feeRecord: prior.recordPlaintext }) + await waitForConfirmation(client, id) + await client.reconcileRecordInventory() + const entries = await store.list(scope) + expect(entries[1]!.status).toBe('confirmed') + expect(entries[1]!.feeRecordId).not.toBe(firstFee) + expect(entries[1]!.records).toEqual([entries[1]!.feeRecordId]) + }, 120_000) + + it('retains a private fee consumed by an execution rejected on chain', async () => { + // The seeded public balance cannot cover this transfer. Its finalize fails, + // but the separately authorized private fee is still charged by the ledger. + const id = await client.writeContract({ program: 'credits.aleo', function: 'transfer_public', + inputs: [client.account!.address, '18446744073709551615u64'], privateFee: true }) + await expect(waitForConfirmation(client, id)).rejects.toThrow() + await client.reconcileRecordInventory() + const entry = (await store.list(scope)).at(-1)! + expect(entry.status).toBe('rejected') + const inventory = await client.getRecordInventory({ asset: { program: 'credits.aleo' } }) + expect(inventory.reserved.some(record => record.id === entry.feeRecordId)).toBe(true) + }, 120_000) + + it('fails before submission when every remaining credits record is reserved', async () => { + const available = await client.getRecordInventory({ asset: { program: 'credits.aleo' } }) + expect(available.available).toHaveLength(1) + await store.acquire({ scope, records: available.available.map(record => record.id), program: 'credits.aleo', function: 'join', + status: 'prepared', accountType: 'local', createdAt: Date.now() }) + const count = (await store.list(scope)).length + await expect(client.writeContract({ program: 'credits.aleo', function: 'transfer_public', + inputs: [client.account!.address, '1u64'], privateFee: true })).rejects.toThrow('No available credits record') + expect(await store.list(scope)).toHaveLength(count) + }, 60_000) +}) diff --git a/packages/provable-sdk/test/privateFees.test.ts b/packages/provable-sdk/test/privateFees.test.ts new file mode 100644 index 00000000..88087eb3 --- /dev/null +++ b/packages/provable-sdk/test/privateFees.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import * as sdk from '@provablehq/sdk/testnet.js' +import { loadNetwork, createDevnodeClient, type AleoSdk } from '../src/index.js' + +let aleo: AleoSdk +beforeAll(async () => { aleo = await loadNetwork('testnet') }, 60_000) +afterEach(() => vi.restoreAllMocks()) + +// Exercise Veil's real adapter while replacing only WASM proving and network I/O. +// The reserved record must reach each SDK entry point unchanged. +describe('reserved private fee record adapter', () => { + it.each(['local', 'delegated'] as const)('passes the selected fee record to %s build and execute', async mode => { + const tx = { id: 'at1fee', type: 'execute', execution: { transitions: [] } } + const build = vi.spyOn(sdk.ProgramManager.prototype, 'buildExecutionTransaction').mockResolvedValue({ toString: () => JSON.stringify(tx) } as never) + const request = vi.spyOn(sdk.ProgramManager.prototype, 'provingRequest').mockResolvedValue({ encrypted: true } as never) + vi.spyOn(sdk.AleoNetworkClient.prototype, 'submitProvingRequestSafe').mockResolvedValue({ ok: true, data: { transaction: tx } } as never) + const config = aleo.createProvingConfig({ mode, networkUrl: 'https://node.example', useFeeMaster: true, account: aleo.generateAccount() }) + const options = { programName: 'token.aleo', functionName: 'transfer_private', inputs: [], privateFee: true, feeRecord: 'selected-record' } + await config.buildTransaction!(options) + await expect(config.execute!({ ...options, fee: 0n, onProgress() { throw new Error('checkpoint stops broadcast') } })).rejects.toThrow('checkpoint stops broadcast') + const calls = mode === 'delegated' ? request.mock.calls : build.mock.calls + expect(calls).toHaveLength(2) + for (const [params] of calls) expect(params).toMatchObject({ feeRecord: 'selected-record', privateFee: true, + ...(mode === 'delegated' ? { useFeeMaster: false } : {}) }) + }) + it('estimates base fees in microcredits and resolves dynamic imports', async () => { + const estimate = vi.spyOn(sdk.ProgramManager.prototype, 'estimateExecutionFee').mockResolvedValue(12345n) + vi.spyOn(sdk.AleoNetworkClient.prototype, 'getProgram').mockImplementation(async name => `program ${name};`) + vi.spyOn(sdk.AleoNetworkClient.prototype, 'getProgramImports').mockResolvedValue({}) + const config = aleo.createProvingConfig({ mode: 'delegated', networkUrl: 'https://node.example' }) + expect(await config.estimateExecutionFee!({ programName: 'router.aleo', functionName: 'join_3', imports: ['token.aleo'] })).toBe(12345n) + expect(estimate).toHaveBeenCalledWith(expect.objectContaining({ programName: 'router.aleo', functionName: 'join_3', imports: { 'token.aleo': 'program token.aleo;' } })) + }) + it('passes the selected fee record to the devnode builder', async () => { + const build = vi.spyOn(sdk.ProgramManager.prototype, 'buildDevnodeExecutionTransaction').mockResolvedValue({ toString: () => JSON.stringify({ id: 'at1devnode' }) } as never) + const { walletClient } = createDevnodeClient() + await walletClient.proving!.buildTransaction!({ programName: 'credits.aleo', functionName: 'join', inputs: [], privateFee: true, feeRecord: 'selected-record' } as any) + expect(build).toHaveBeenCalledWith(expect.objectContaining({ feeRecord: 'selected-record', privateFee: true })) + }) +}) diff --git a/packages/provable-sdk/test/provable.test.ts b/packages/provable-sdk/test/provable.test.ts index 018cdc81..3c2a5307 100644 --- a/packages/provable-sdk/test/provable.test.ts +++ b/packages/provable-sdk/test/provable.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, it, expect, vi } from 'vitest' +import { memoryRecordInventoryStore, reservedRecordIds } from '@provablehq/veil-core' import * as testnetSdk from '@provablehq/sdk/testnet.js' import { loadNetwork, @@ -379,6 +380,26 @@ describe('@provablehq/veil-aleo-sdk', () => { }) }) + describe('record management configuration', () => { + it.each(['aleo', 'devnode'] as const)('preserves explicit opt-in through the %s client factory', async (factory) => { + const store = memoryRecordInventoryStore() + const account = aleo.generateAccount() + const scope = JSON.stringify(['aleo:isolated-test', account.address]) + await store.acquire({ scope, program: 'credits.aleo', function: 'split', records: ['1group'], + status: 'reserved', createdAt: Date.now(), accountType: 'local' }) + // The journal is observable through selection only after the factory forwards opt-in. + for (const enabled of [undefined, false, true]) { + const options = { privateKey: account.privateKey, + ...(enabled === undefined ? {} : { recordManagement: { enabled, store, chainId: 'aleo:isolated-test' } }), + } + const { walletClient, publicClient } = factory === 'aleo' + ? aleo.createAleoClient(options) : createDevnodeClient(options) + expect(await reservedRecordIds(walletClient)).toEqual(new Set(enabled === true ? ['1group'] : [])) + expect(await reservedRecordIds(publicClient)).toEqual(new Set()) + } + }) + }) + describe('createAleoClient', () => { it('returns publicClient, walletClient, and account', () => { const account = aleo.generateAccount() diff --git a/packages/provable-sdk/test/scannerSync.test.ts b/packages/provable-sdk/test/scannerSync.test.ts new file mode 100644 index 00000000..82ccda1f --- /dev/null +++ b/packages/provable-sdk/test/scannerSync.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import * as sdk from '@provablehq/sdk/testnet.js' +import { loadNetwork, type AleoSdk } from '../src/index.js' + +describe('inventory scanner synchronization', () => { + let aleo: AleoSdk + beforeAll(async () => { aleo = await loadNetwork('testnet') }, 60_000) + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() }) + const setup = () => { + const register = vi.spyOn(sdk.RecordScanner.prototype, 'registerEncrypted').mockResolvedValue({ ok: true, data: {} } as never) + const status = vi.spyOn(sdk.RecordScanner.prototype, 'status').mockResolvedValue({ ok: true, data: { synced: true } } as never) + const owned = vi.spyOn(sdk.RecordScanner.prototype, 'owned').mockResolvedValue({ ok: true, data: [] } as never) + const scanner = aleo.createRemoteScanner({ waitForSync: true, syncTimeoutMs: 10, startBlock: 123 }) + scanner.setAccount!(aleo.generateAccount()) + return { scanner, register, status, owned } + } + it('recovers an invalidated registration before reading owned records', async () => { + const { scanner, register, status, owned } = setup() + status.mockResolvedValueOnce({ ok: false, status: 422 } as never) + await expect(scanner.requestRecords({ program: 'credits.aleo' })).resolves.toEqual([]) + expect(register).toHaveBeenCalledTimes(2) + expect(register).toHaveBeenLastCalledWith(expect.anything(), 123) + expect(status).toHaveBeenCalledTimes(2) + expect(owned).toHaveBeenCalledTimes(1) + }) + it('does not present a partially synced scanner as complete inventory', async () => { + const { scanner, status, owned } = setup() + vi.useFakeTimers() + status.mockResolvedValue({ ok: true, data: { synced: false } } as never) + const result = expect(scanner.requestRecords({ program: 'credits.aleo' })).rejects.toThrow('synchronization timed out') + await vi.runAllTimersAsync() + await result + expect(owned).not.toHaveBeenCalled() + }) + it('bounds invalid-registration recovery and validates its deadline', async () => { + const { scanner, status, register } = setup() + status.mockResolvedValue({ ok: false, status: 422 } as never) + await expect(scanner.requestRecords({ program: 'credits.aleo' })).rejects.toThrow('status failed') + expect(register).toHaveBeenCalledTimes(2) + expect(() => aleo.createRemoteScanner({ syncTimeoutMs: 0 })).toThrow('syncTimeoutMs') + }) +}) diff --git a/packages/shield-swap-cli/INVENTORY.md b/packages/shield-swap-cli/INVENTORY.md new file mode 100644 index 00000000..219c8690 --- /dev/null +++ b/packages/shield-swap-cli/INVENTORY.md @@ -0,0 +1,183 @@ +# Record inventory management + +`shield-swap inventory` manages spendable records for native `credits.aleo`, +ARC20, ARC22, and compatible custom assets. It reads records from the scanner, +plans joins and splits, and keeps a journal for recovery after interruptions. +Requires Node **22.13+** for built-in SQLite. SDK packages retain their existing +runtime requirements. + +## Start + +Install `@provablehq/shield-swap-cli`. Inventory commands use the account saved +by `shield-swap setup` for the selected network. To select another account, set +`ALEO_PRIVATE_KEY` through the environment or a service manager's secret facility; +`privateKeyEnv` changes the variable name. Inventory does not require DEX +authentication. Amounts are integer token base units; credits use microcredits. + +```sh +shield-swap inventory inspect --asset credits.aleo +shield-swap inventory plan --asset credits.aleo --records 4 +shield-swap inventory rebalance --asset credits.aleo --records 4 --execute +shield-swap inventory run --config inventory.json --execute +shield-swap inventory status --config inventory.json +``` + +`plan` never signs. `rebalance` and `run` also only print a plan unless +`--execute` is supplied. `run --execute` stays in the foreground; run it under +systemd, launchd, or another process supervisor for background operation. +Within this repository, use `pnpm shield-swap inventory ...`. Add `-s` to pnpm +when parsing JSON: `pnpm -s shield-swap inventory status --json`. + +`inspect`, `plan`, `rebalance`, and `status` accept `--json` and return one +object with the network, account address, and results. For `run --execute`, +`--json` prints one summary when the process receives SIGINT or SIGTERM; it +includes the cycle count and the last cycle's results. Without `--json`, the +loop prints actionable results as they occur. + +## Policy configuration + +```json +{ + "network": "testnet", + "database": ".veil/inventory.sqlite", + "privateKeyEnv": "ALEO_PRIVATE_KEY", + "intervalMs": 30000, + "cooldownMs": 60000, + "maxTransactions": 10, + "maxFeeMicrocredits": "1000000", + "maxDailyFeeMicrocredits": "10000000", + "useFeeMaster": false, + "policies": [ + { + "asset": { "program": "credits.aleo" }, + "target": { "records": 4, "minRecordAmount": "1000000", "distribution": "preserve" }, + "countRange": [3, 6] + } + ] +} +``` + +These settings show the defaults for application limits. The default network is +testnet, the default gateway is `https://edge.provable.com/api/v2`, and the default +journal is `.veil/inventory.sqlite`, relative to the working directory. +`--network` overrides the config file, then `SHIELD_SWAP_NETWORK`, then testnet. +`--database` overrides `VEIL_INVENTORY_DB`, which overrides the config file. +Account files follow `SHIELD_SWAP_STATE_DIR`, as with trading commands. Set +`networkUrl` for another node and `chainId` for a distinct chain; participants +MUST use the same identity (default `aleo:testnet` or `aleo:mainnet`). Do not +reuse the default identity across independent local chains. + +- `records`: target count, 1–1000. `minRecordAmount` defaults to `"1"`. +- `distribution`: `preserve` avoids reshaping sufficient records; `balanced` + requests approximately equal lots, within `toleranceBps` (default 1000, or 10%). +- `countRange`: optional inclusive range containing the target. While the count + is in range and every record meets the minimum, maintenance does nothing, + including for a balanced policy. Omit it to enforce the target distribution. +- `maxTransactions`: total planned transitions per maintenance pass. +- `tokenJoin`: optional dynamic ARC router override. By default, core selects + available helpers for the asset and network. Credits and dedicated stablecoin + helpers accept up to 16 records; dynamic ARC helpers accept up to 15. + Set `{ "program": "custom_router.aleo", "maxRecords": 15 }` for a custom + dynamic router, or `false` for native pairwise joins on every asset. + Transaction limits count batch submissions, not their nested token calls. +- `maxFeeMicrocredits`: maximum fee for one transaction and total network-fee + budget for one asset's rebalance. Fees are checked on the proved transaction + before broadcast. `useFeeMaster: true` requests sponsored delegated proving. +- `maxDailyFeeMicrocredits`: rolling 24-hour maintenance limit. Counts journalled + network fees and the intrinsic 10,000-microcredit deduction per credits split. + Rejected/uncertain entries count conservatively. Participating trades contribute + to recorded spend, but this setting does not impose a global trading budget. +- `cooldownMs`: delay after an operation on an asset; defaults to one minute. + +An asset requires only `program`. Use the token's underlying program. +`recordName` and `amountField` override the detected record and balance field; +`standard` remains an optional hint. ARC tokens default to `Token.amount` and +credits to `credits.microcredits`. Custom records use `amount`, then +`microcredits`, unless a field is supplied. For a custom record: + +```sh +shield-swap inventory plan --asset points.aleo --record-name Balance --amount-field points --records 4 +``` + +The same names work in JSON: `"asset": { "program": "points.aleo", +"recordName": "Balance", "amountField": "points" }`. Bound, compliance, and +registry records are excluded. The application reuses `createRemoteScanner` +and waits for its reported synchronization before planning. See the +[record inventory guide](../../site/docs/guides/record-inventory.md) for helper +programs, field defaults, and execution requirements. + +## Recovery and shared trading + +The scanner owns record discovery. The SQLite journal stores account/chain scope, +input nonces, operation state, transaction ids, fees, and the **proved encrypted +transaction** while it is pending. It stores neither keys nor record plaintext. +Routed joins also record the underlying asset program so cooldowns remain per asset. +It creates the database with mode 0600 and new parent directories with mode 0700. + +Each operation reserves inputs atomically, proves, saves the complete proved +transaction, and only then broadcasts. After acceptance, dependent steps wait +for those exact output commitments to appear in the scanner. An interrupted run +keeps completed chain operations; the next pass reconciles and plans from fresh +inventory. It never attempts to undo accepted joins or splits. + +Rejected executions release their inputs but keep any charged private fee +record reserved. A timeout or 404 does not release inputs. Recovery can resubmit the saved +transaction with the same proof and id. Confirmed inputs remain excluded to +protect against stale scans. A local process that died before saving a proved +transaction can release its reservation; an uncertain wallet submission cannot. +No automatic age-based unlock is performed. Unresolved operations block further +maintenance until chain evidence resolves them; `status` displays their ids. +Keep the journal across restarts and **do not delete it to clear a pending spend**. +Recovery after indefinite dropped/rejected-without-chain-evidence transactions +requires operator reconciliation; no force-unlock command is included. + +If a saved transaction fails to rebroadcast, reconciliation records +`lastRebroadcastError` and increments `rebroadcastAttempts`, then checks the +remaining entries. Maintenance reports `waiting` while any spend is unresolved. +Inspect all entries, including their last errors, with: + +```sh +shield-swap inventory status --config inventory.json --json +``` + +These fields survive a restart. A successful rebroadcast or confirmed outcome +clears the error; the attempt count remains. A broadcast failure does not mark +an entry rejected or release its execution or fee records. + +Only one manager can run for an account and chain using the same journal. +Pending trading operations take priority over maintenance. Trading clients MUST +open the same file and enable record coordination when creating the client: + +```ts +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' + +const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') +const aleo = await loadNetwork('testnet') +const { walletClient } = aleo.createAleoClient({ + privateKey, // supplied by the application + recordManagement: { + enabled: true, store, chainId: 'aleo:testnet', + }, +}) +// Extend walletClient with shieldSwapActions(...) when trading. +// Close the store when the application shuts down. +``` + +For trading commands in the same CLI, set `VEIL_INVENTORY_DB` to this database +path and select the same network. This option requires Node 22.13+. Inventory +commands enable coordination for their own client. Trading commands and SDK +clients leave it off by default, including clients extended with `recordActions()`. + +Applications can implement `RecordInventoryStore` with their own storage +without depending on this package. Reservations cover only clients that use the +same journal; unmanaged wallets and raw submissions bypass them. Managed local SDK writes can pay privately: fee selection excludes reserved +credits and execution inputs, and reserves the selected fee record with the +transaction. Wallet RPC writes require public fees or FeeMaster until the wallet +protocol can pin a fee record. The maintenance CLI uses public fees by default +or FeeMaster when `useFeeMaster` is true. + +SIGINT/SIGTERM stops new work after the current operation returns. Proving or +an in-flight request may delay shutdown; durable checkpoints survive forced +termination. Avoid running maintenance against an account used by an +uncoordinated wallet. diff --git a/packages/shield-swap-cli/README.md b/packages/shield-swap-cli/README.md index 2612d605..4d5a7da7 100644 --- a/packages/shield-swap-cli/README.md +++ b/packages/shield-swap-cli/README.md @@ -66,6 +66,9 @@ pnpm --filter @provablehq/shield-swap-cli build node packages/shield-swap-cli/dist/index.js pools ``` +`pnpm --filter @provablehq/shield-swap-cli test:package` builds the CLI and +checks its command help and SQLite storage export. Requires Node 22.13+. + State lands in `./.shield-swap//` relative to the working directory, so running from the repo root keeps a session there. Set `SHIELD_SWAP_STATE_DIR` to point somewhere else — worth doing if you want a scratch account separate from @@ -118,6 +121,7 @@ FeeMaster account by default, so a faucet-funded account needs no public credits | `redeem` | Redeems a referral code, or gets/creates the account's shareable code with `--generate`. | Session; referral code when redeeming | | `pools` | Lists pools from the API and joins each with chain state, so the tradeable flag and the depth come from the mappings rather than the index. | Session | | `balances` | Private and public holdings per token, reconciled against the registry. | Session, record access | +| `inventory` | Inspect, plan, rebalance, or maintain spendable records; report pending operations. | Account, record access, Node 22.13+ | | `positions` | Every liquidity position the account holds, with its range, its backing amounts, and the fees earned that a collect would pay. | Session, record access | | `swap` | Sells one token for another, single hop or routed, then claims the output. | Funds in the token being sold | | `swap-concurrent` | Makes multiple swaps concurrently, one per token sold, planned before any is submitted. | Funds in each token being sold | @@ -232,3 +236,38 @@ Existing CLI commands keep literal proof behavior. Populated compliance lists require a concrete proof provider in a customized SDK client; the all-zero legacy witness is not a general exclusion proof. Collection forwards the position's withdrawal address as the proof subject. + +## Record inventory management + +`shield-swap inventory` inspects records, plans joins and splits, and maintains +record counts and amounts for native credits, ARC20, ARC22, or custom assets. +It uses the configured Shield Swap account or a key supplied through the +environment. These commands require Node 22.13+ and do not need DEX authentication. + +```sh +shield-swap inventory inspect --asset credits.aleo +shield-swap inventory plan --asset credits.aleo --records 4 --json +shield-swap inventory rebalance --asset credits.aleo --records 4 --execute +shield-swap inventory run --config inventory.json --execute +shield-swap inventory status --json +``` + +`rebalance` and `run` only print plans without `--execute`. See the +[inventory configuration and recovery reference](INVENTORY.md) for policies, +fee limits, and continuous operation, and the +[record inventory guide](../../site/docs/guides/record-inventory.md) for SDK use. + +Set `VEIL_INVENTORY_DB` to the journal path used by `shield-swap inventory run` +so trading and maintenance cannot reserve the same explicit record inputs. +The trading session enables `recordManagement: { enabled: true, store, chainId }` +when it creates the client. The SQLite store is also available to applications +from `@provablehq/shield-swap-cli/storage`. Automatic swap selection then tries +another covering record if maintenance reserves its first choice before proving. +The `swap-concurrent` command retains its one-leg-per-input-token limit; SDK +scripts can submit same-token swaps with enough covering records and a shared +blinded identity store. + +Without the environment variable, trading commands keep record coordination +disabled and open no inventory journal. Inventory commands explicitly enable +coordination and default to `.veil/inventory.sqlite`. Keep this file across +restarts so pending transactions can be reconciled. diff --git a/packages/shield-swap-cli/package.json b/packages/shield-swap-cli/package.json index 98b0ae97..49816795 100644 --- a/packages/shield-swap-cli/package.json +++ b/packages/shield-swap-cli/package.json @@ -14,6 +14,10 @@ "./session": { "types": "./dist/session.d.ts", "import": "./dist/session.js" + }, + "./storage": { + "types": "./dist/storage.d.ts", + "import": "./dist/storage.js" } }, "bin": { @@ -22,7 +26,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:package": "pnpm build && node --test test/package-smoke.mjs" }, "files": [ "dist" @@ -35,7 +40,8 @@ }, "dependencies": { "@provablehq/shield-swap-sdk": "workspace:^", - "@provablehq/veil-aleo-sdk": "workspace:^" + "@provablehq/veil-aleo-sdk": "workspace:^", + "@provablehq/veil-core": "workspace:^" }, "keywords": [ "aleo", diff --git a/packages/shield-swap-cli/src/commands/inventory.ts b/packages/shield-swap-cli/src/commands/inventory.ts new file mode 100644 index 00000000..70f41729 --- /dev/null +++ b/packages/shield-swap-cli/src/commands/inventory.ts @@ -0,0 +1,144 @@ +import { readFile } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { sqliteRecordInventoryStore } from '../storage.js' +import { parseInventoryConfig } from '../inventory/config.js' +import { runInventoryCycle, type InventoryCycleResult } from '../inventory/runner.js' +import { loadState, resolveNetwork } from '../session.js' +import { flags, output, reportUsage, run } from '../shared.js' + +const HELP = `shield-swap inventory + + --config JSON policy configuration + --asset One-off asset program (credits, ARC, or custom records) + --standard Optional credits, arc20 or arc22 interface hint + --record-name Override the record type selected for inventory + --amount-field Override the record field used for amounts + --records One-off target count, default 1 + --network testnet (default) or mainnet + --database Shared SQLite journal; also VEIL_INVENTORY_DB + --execute Submit transactions; omission only prints plans + --json One result object; run emits its summary on shutdown + --no-color Plain output + -h, --help Show this help + +Amounts in configuration are decimal strings in token base units. +Uses ALEO_PRIVATE_KEY (or config.privateKeyEnv), then the Shield Swap account. +run stays in the foreground for a service manager; Ctrl-C preserves pending work. +Node 22.13 or newer is required. No DEX authentication is needed.` + +// Keep base-unit amounts exact for custom assets, including u128 token balances. +const print = (value: unknown) => output(value, data => console.log(JSON.stringify(data, (_, item) => typeof item === 'bigint' ? item.toString() : item, 2))) + +/** + * Dispatches record inspection, planning, maintenance, and journal status. + * Reads the configured account without DEX authentication. Only explicit + * --execute on rebalance or run may prove and submit transactions. + * @param argv Arguments following `shield-swap inventory`. + * @returns Resolves after output or shutdown; failures set the process exit code. + * @example + * await main(['plan', '--asset', 'credits.aleo', '--records', '4', '--json']) + */ +export async function main(argv: string[]): Promise { + // Parse the inventory verb separately, then let the shared parser enforce + // the same help, unknown-flag, and JSON error conventions as trading commands. + if (!argv.length || argv[0] === '--help' || argv[0] === '-h') { + flags({}, HELP, ['--help']) + return + } + const [command, ...args] = argv + if (!['inspect', 'plan', 'rebalance', 'run', 'status'].includes(command!)) reportUsage(`Unknown inventory command: ${command}`, HELP, argv) + const values = flags({ + config: { type: 'string' }, asset: { type: 'string' }, standard: { type: 'string' }, + 'record-name': { type: 'string' }, 'amount-field': { type: 'string' }, records: { type: 'string' }, database: { type: 'string' }, + }, HELP, args) + await run(async () => { + // Apply command-line overrides before validating policies. Network state + // and the signing key follow the existing Shield Swap session conventions. + const raw = values.config ? JSON.parse(await readFile(values.config as string, 'utf8')) : {} + raw.network = resolveNetwork(values.network ?? raw.network) + if (values.database || process.env.VEIL_INVENTORY_DB) raw.database = values.database ?? process.env.VEIL_INVENTORY_DB + if (values.asset) raw.policies = [{ asset: { program: values.asset, standard: values.standard, recordName: values['record-name'], amountField: values['amount-field'] }, target: { records: Number(values.records ?? '1') } }] + const config = parseInventoryConfig(raw) + if (command !== 'status' && !config.policies.length) throw new Error('Supply --asset or configure at least one inventory policy') + const privateKey = process.env[config.privateKeyEnv] || loadState(config.network).privateKey + if (!privateKey) throw new Error(`Set ${config.privateKeyEnv} or run shield-swap setup --network ${config.network} to configure an account`) + const aleo = await loadNetwork(config.network) + const store = await sqliteRecordInventoryStore(config.database) + let scope: string | undefined + let lease: string | undefined + const controller = new AbortController() + const stop = () => controller.abort() + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + try { + // Inventory opts into coordination explicitly. Ordinary trading remains + // unmanaged unless its session is configured with VEIL_INVENTORY_DB. + const { walletClient: client, account } = aleo.createAleoClient({ privateKey, networkUrl: config.networkUrl, useFeeMaster: config.useFeeMaster, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: config.chainId, maxFeeMicrocredits: config.maxFeeMicrocredits, tokenJoin: config.tokenJoin } }) + scope = JSON.stringify([config.chainId, account.address]) + const identity = { network: config.network, address: account.address } + if (command === 'status') { + // Print operation metadata without the stored proved transaction payload. + print({ ...identity, operations: (await store.list(scope)).map(({ transaction: _transaction, ...entry }) => entry) }) + return + } + if (command === 'inspect') { + const inventories = [] + for (const policy of config.policies) { + const inventory = await client.getRecordInventory({ asset: policy.asset }) + inventories.push({ asset: policy.asset, balance: inventory.balance, + available: inventory.available.map(({ id, amount }) => ({ id, amount })), + reserved: inventory.reserved.map(({ id, amount }) => ({ id, amount })) }) + } + print({ ...identity, inventories }) + return + } + // All write commands stop at planning unless --execute was supplied. + if (command === 'plan' || !values.execute) { + const plans = [] + for (const policy of config.policies) plans.push(await client.planRecordInventory({ ...policy, maxTransactions: config.maxTransactions })) + print({ ...identity, plans }) + return + } + // Reserve a synthetic record id to allow only one maintenance loop per + // account and chain, while trading reserves actual record nonces. + const lock = await store.acquire({ scope, records: ['__inventory_manager__'], program: '__inventory_manager__', function: 'run', + status: 'reserved', createdAt: Date.now(), accountType: 'local' }) + if (!lock) throw new Error('Another inventory manager is already running for this account and chain') + lease = lock.id + let cycles = 0 + let results: InventoryCycleResult[] = [] + let lastError: string | undefined + do { + cycles++ + try { + results = await runInventoryCycle(client, config, store, scope, controller.signal) + lastError = undefined + // Human service logs report actionable cycles. JSON accumulates only + // the latest outcome and emits one bounded summary on shutdown. + if (command !== 'run' || (!values.json && results.some(result => !['satisfied', 'cooldown'].includes(result.status)))) print({ ...identity, results }) + if (command !== 'run' && results.some(result => ['interrupted', 'unavailable', 'waiting', 'budget'].includes(result.status))) process.exitCode = 1 + } catch (error) { + if (command !== 'run') throw error + results = [] + lastError = error instanceof Error ? error.message : String(error) + if (!values.json) console.error(lastError) + } + if (command !== 'run' || controller.signal.aborted) break + await delay(config.intervalMs, undefined, { signal: controller.signal }).catch(error => { if (!controller.signal.aborted) throw error }) + } while (!controller.signal.aborted) + if (command === 'run' && values.json) print({ ...identity, cycles, results, ...(lastError ? { error: { message: lastError } } : {}) }) + } finally { + // Release only the manager lease; transaction reservations must survive + // shutdown so the next process can reconcile their chain status. + try { if (lease && scope) await store.update(scope, lease, { status: 'cancelled' }) } + finally { + store.close() + process.removeListener('SIGINT', stop) + process.removeListener('SIGTERM', stop) + } + } + }) +} diff --git a/packages/shield-swap-cli/src/commands/swap-concurrent.ts b/packages/shield-swap-cli/src/commands/swap-concurrent.ts index 427add44..65d66d3f 100644 --- a/packages/shield-swap-cli/src/commands/swap-concurrent.ts +++ b/packages/shield-swap-cli/src/commands/swap-concurrent.ts @@ -1,16 +1,11 @@ /** * Concurrent swaps — several trades in flight at once. * - * Two things contend when swaps run in parallel, and only one is handled for you: - * - * Blinded identities are safe. Each swap reserves its own from the store the - * session configures, and reservations serialize, so two swaps cannot derive - * the same address and have the second revert on the uniqueness assert. - * - * Records are not. Selection picks ONE private record big enough for the - * amount, so two swaps selling the same token can pick the same record and one - * fails as a double-spend. This script therefore refuses to run two swaps that - * sell the same token, rather than letting the chain reject them. + * The session reserves blinded identities. Record coordination is enabled only + * when VEIL_INVENTORY_DB selects a journal; otherwise automatic selection can + * choose the same input twice. This command keeps its one-leg-per-input-token + * policy in either mode. SDK callers with enabled coordination can also run + * same-token swaps against separate covering records. * * Every swap is planned before any is submitted, so a bad leg is caught while * nothing has been spent. @@ -85,7 +80,7 @@ export async function main(argv: string[]): Promise { legs.push(plan) } - // The one hazard the store does not cover. + // Keep the CLI batch policy independent of optional record coordination. const sellers = legs.map((leg) => leg.from.id) const duplicated = sellers.filter((id, index) => sellers.indexOf(id) !== index) if (duplicated.length) { diff --git a/packages/shield-swap-cli/src/inventory/config.ts b/packages/shield-swap-cli/src/inventory/config.ts new file mode 100644 index 00000000..a333c268 --- /dev/null +++ b/packages/shield-swap-cli/src/inventory/config.ts @@ -0,0 +1,124 @@ +import type { InventoryTarget, RecordAsset, TokenJoinRouter } from '@provablehq/veil-core' + +/** + * Defines one asset's maintenance policy in base units. + * @property asset Program with optional record-name and amount-field overrides. + * @property target Desired record count, distribution and minimum in base units. + * @property countRange Optional inclusive range suppressing reshaping while the minimum holds. + * @internal + */ +export type InventoryPolicy = { + asset: RecordAsset + target: InventoryTarget + countRange?: [number, number] +} + +/** + * Configures the inventory application without embedding a private key. + * @property network Aleo testnet or mainnet; defaults to testnet. + * @property networkUrl HTTP node gateway; defaults to edge.provable.com/api/v2. + * @property chainId Shared reservation identity; defaults to aleo plus the network name. + * @property privateKeyEnv Signing-key environment variable; defaults to ALEO_PRIVATE_KEY. + * @property database Journal filename; defaults to .veil/inventory.sqlite. + * @property policies Asset policies, empty by default. + * @property intervalMs Milliseconds between passes; defaults to 30000. + * @property cooldownMs Milliseconds after an asset operation; defaults to 60000. + * @property maxTransactions Maximum transitions per pass; defaults to 10. + * @property maxFeeMicrocredits Transaction/asset-pass network-fee ceiling; defaults to 1000000. + * @property maxDailyFeeMicrocredits Rolling 24-hour maintenance ceiling; defaults to 10000000. + * @property useFeeMaster Requests delegated sponsorship; defaults to false. + * @property tokenJoin Optional dynamic ARC router override. Omission selects available helpers by asset and network; false uses native pairwise joins. + * @internal + */ +export type InventoryConfig = { + network: 'testnet' | 'mainnet' + networkUrl: string + chainId: string + privateKeyEnv: string + database: string + policies: InventoryPolicy[] + intervalMs: number + cooldownMs: number + maxTransactions: number + maxFeeMicrocredits: bigint + maxDailyFeeMicrocredits: bigint + useFeeMaster: boolean + tokenJoin?: TokenJoinRouter | false +} + +/** + * Validates JSON configuration and converts decimal base-unit amounts to bigint. + * @param value Parsed configuration with amounts encoded as decimal strings. + * @returns Validated settings; omitted limits use documented bounded defaults. + * @throws On unknown networks, invalid amounts, duplicate assets or impossible count ranges. + * @example + * const config = parseInventoryConfig({ network: 'testnet', policies: [] }) + */ +export function parseInventoryConfig(value: unknown): InventoryConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Inventory config must be an object') + const raw = value as Record + // Normalize JSON fields once so counts remain bounded numbers and monetary + // amounts retain their full precision as bigint throughout planning. + const integer = (value: unknown, fallback: number, max: number, zero = false) => { + const n = value === undefined ? fallback : value + if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < (zero ? 0 : 1) || n > max) throw new Error('Invalid inventory count or interval') + return n + } + const amount = (value: unknown, fallback: bigint) => { + if (value === undefined) return fallback + if (typeof value !== 'string' || !/^\d+$/.test(value)) throw new Error('Amounts must be non-negative decimal strings in base units') + return BigInt(value) + } + const string = (value: unknown, fallback: string) => { + if (value === undefined) return fallback + if (typeof value !== 'string' || !value.trim()) throw new Error('Expected a non-empty configuration string') + return value + } + const network = raw.network ?? 'testnet' + if (network !== 'testnet' && network !== 'mainnet') throw new Error('Network must be testnet or mainnet') + // Let core select a deployed helper for each asset. False explicitly keeps + // native pairwise joins; an object overrides dynamic token routing. + const router = raw.tokenJoin + let tokenJoin: TokenJoinRouter | false | undefined = router === false ? false : undefined + if (router !== undefined && router !== false) { + if (!router || typeof router !== 'object' || Array.isArray(router)) throw new Error('Invalid tokenJoin router') + const configured = router as Record + if (typeof configured.program !== 'string' || !/^[a-z][a-z0-9_]*\.aleo$/.test(configured.program)) throw new Error('Invalid tokenJoin program') + const maxRecords = integer(configured.maxRecords, 15, 15) + if (maxRecords < 2) throw new Error('tokenJoin maxRecords must be from 2 to 15') + tokenJoin = { program: configured.program, maxRecords } + } + if (raw.privateKey !== undefined) throw new Error('Use privateKeyEnv instead of storing a private key in inventory configuration') + if (raw.useFeeMaster !== undefined && typeof raw.useFeeMaster !== 'boolean') throw new Error('useFeeMaster must be boolean') + if (!Array.isArray(raw.policies ?? [])) throw new Error('policies must be an array') + // Validate each asset and its target together, including the optional count + // range that suppresses unnecessary maintenance while inventory is usable. + const policies = ((raw.policies ?? []) as Record[]).map((policy): InventoryPolicy => { + const asset = policy.asset as RecordAsset + if (!asset || !/^[a-z][a-z0-9_]*\.aleo$/.test(asset.program) || (asset.standard !== undefined && !['credits', 'arc20', 'arc22'].includes(asset.standard))) throw new Error('Invalid inventory asset') + if (asset.standard !== undefined && (asset.standard === 'credits') !== (asset.program === 'credits.aleo')) throw new Error('credits.aleo requires the credits standard') + if ([asset.recordName, asset.amountField].some(name => name !== undefined && (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)))) throw new Error('Invalid record or amount field name') + const target = policy.target as Record | undefined + if (!target) throw new Error('Each policy requires a target') + const records = integer(target.records, 1, 1000) + const minRecordAmount = amount(target.minRecordAmount, 1n) + if (!minRecordAmount) throw new Error('Minimum record amount must be positive') + const distribution = target.distribution ?? 'preserve' + if (distribution !== 'preserve' && distribution !== 'balanced') throw new Error('Invalid inventory distribution') + const range = policy.countRange as number[] | undefined + if (range && (!Array.isArray(range) || range.length !== 2 || range.some((n) => !Number.isSafeInteger(n) || n < 1 || n > 1000) || range[0]! > records || range[1]! < records)) throw new Error('countRange must contain the target count') + return { asset: { ...asset }, target: { records, minRecordAmount, distribution, + toleranceBps: integer(target.toleranceBps, 1000, 10_000, true) }, countRange: range as [number, number] | undefined } + }) + // Reject duplicate policies so one asset cannot be assigned competing targets + // within a single maintenance cycle. + if (new Set(policies.map((policy) => policy.asset.program)).size !== policies.length) throw new Error('Duplicate inventory asset policy') + const networkUrl = string(raw.networkUrl, 'https://edge.provable.com/api/v2') + const url = new URL(networkUrl) + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('networkUrl must use HTTP or HTTPS') + return { network, networkUrl, chainId: string(raw.chainId, `aleo:${network}`), + privateKeyEnv: string(raw.privateKeyEnv, 'ALEO_PRIVATE_KEY'), database: string(raw.database, '.veil/inventory.sqlite'), policies, + intervalMs: integer(raw.intervalMs, 30_000, 2_147_483_647), cooldownMs: integer(raw.cooldownMs, 60_000, 2_147_483_647, true), + maxTransactions: integer(raw.maxTransactions, 10, 1000), maxFeeMicrocredits: amount(raw.maxFeeMicrocredits, 1_000_000n), + maxDailyFeeMicrocredits: amount(raw.maxDailyFeeMicrocredits, 10_000_000n), useFeeMaster: raw.useFeeMaster === true, tokenJoin } +} diff --git a/packages/shield-swap-cli/src/inventory/runner.ts b/packages/shield-swap-cli/src/inventory/runner.ts new file mode 100644 index 00000000..40e4bdf4 --- /dev/null +++ b/packages/shield-swap-cli/src/inventory/runner.ts @@ -0,0 +1,85 @@ +import { getRecordInventory, planRecordInventory, rebalanceRecordInventory, reconcileRecordInventory, + type Client, type InventoryResult, type RecordInventoryStore, type RecordSpend } from '@provablehq/veil-core' +import type { InventoryConfig } from './config.js' + +/** + * Reports one asset's bounded maintenance outcome. + * @property program Asset program, or an asterisk for an account-wide wait. + * @property status Completion, deferral or interruption category. + * @property result Execution progress when a plan was executed. + * @property reason Human-readable deferral or failure detail, when available. + * @internal + */ +export type InventoryCycleResult = { program: string; status: string; result?: InventoryResult; reason?: string } + +/** + * Runs one maintenance pass after reconciling pending transactions. + * Trading reservations take priority; cooldowns and count ranges suppress churn. + * @param client Coordinated account client using the supplied store. + * @param config Validated application limits and policies. + * @param store Journal shared with participating trading clients. + * @param scope Chain/account namespace, matching the enabled record management configuration. + * @param signal Optional shutdown signal, absent by default; stops subsequent work. + * @returns Per-asset progress; never logs key material or record plaintext. + * @throws When reconciliation or storage is unavailable; callers must retry before planning. + * @example + * const results = await runInventoryCycle(client, config, store, scope) + */ +export async function runInventoryCycle(client: Client, config: InventoryConfig, store: RecordInventoryStore, scope: string, signal?: AbortSignal): Promise { + // Resolve earlier submissions before planning new work; any unresolved spend + // takes precedence over maintenance, including another client's trade. + const journal = await reconcileRecordInventory(client, { rebroadcast: true }) + const pending = journal.filter((entry) => entry.program !== '__inventory_manager__' && ['reserved', 'prepared', 'submitted'].includes(entry.status)) + if (pending.length) { + // Report the first failed rebroadcast; status lists diagnostics for every + // entry. Maintenance still waits while any transaction remains unresolved. + const failed = pending.find(entry => entry.lastRebroadcastError !== undefined) + const reason = failed + ? `Unresolved transactions retain their reservations; rebroadcast of ${failed.transactionId ?? failed.id} failed: ${failed.lastRebroadcastError}` + : 'Unresolved transactions retain their reservations' + return [{ program: '*', status: 'waiting', reason }] + } + const results: InventoryCycleResult[] = [] + let transactions = 0 + // Count native split deductions as well as public transaction fees against + // the application's rolling maintenance budget. + const feeCost = (entry: RecordSpend) => BigInt(entry.feeMicrocredits ?? '0') + + (entry.program === 'credits.aleo' && entry.function === 'split' ? 10_000n : 0n) + for (const policy of config.policies) { + if (signal?.aborted) break + const now = Date.now() + // Refresh the shared journal for each asset so earlier steps in this pass + // and other participating clients contribute to the budget and cooldown. + const entries = await store.list(scope) + const spent = entries.filter((entry) => entry.createdAt > now - 86_400_000 && entry.status !== 'cancelled').reduce((sum, entry) => sum + feeCost(entry), 0n) + const latest = entries.filter((entry) => (entry.assetProgram ?? entry.program) === policy.asset.program && entry.status !== 'cancelled').reduce((last, entry) => Math.max(last, entry.createdAt), 0) + if (now - latest < config.cooldownMs) { results.push({ program: policy.asset.program, status: 'cooldown' }); continue } + if (transactions >= config.maxTransactions || spent >= config.maxDailyFeeMicrocredits) { + results.push({ program: policy.asset.program, status: 'budget', reason: 'Maintenance budget exhausted' }); continue + } + try { + const inventory = await getRecordInventory(client, { asset: policy.asset }) + // Skip maintenance while the count is within the configured range and + // every record meets the minimum amount. + const [min, max] = policy.countRange ?? [policy.target.records, policy.target.records] + if (policy.countRange && inventory.available.length >= min && inventory.available.length <= max && + inventory.available.every((record) => record.amount >= (policy.target.minRecordAmount ?? 1n))) { + results.push({ program: policy.asset.program, status: 'satisfied' }); continue + } + const plan = await planRecordInventory(client, { ...policy, maxTransactions: config.maxTransactions - transactions }) + if (!plan.steps.length) { results.push({ program: policy.asset.program, status: 'satisfied' }); continue } + // Reserve the plan's intrinsic deductions first; only the remaining daily + // allowance may be spent on network fees by the sequential executor. + const remaining = config.maxDailyFeeMicrocredits - spent - plan.deduction + if (remaining < 0n) { results.push({ program: policy.asset.program, status: 'budget' }); continue } + const budget = remaining < config.maxFeeMicrocredits ? remaining : config.maxFeeMicrocredits + const result = await rebalanceRecordInventory(client, { plan, maxFeeMicrocredits: budget, signal }) + transactions += result.transactionIds.length + results.push({ program: policy.asset.program, status: result.status, result }) + if (result.status === 'interrupted') break // Reconcile before any more writes. + } catch (error) { + results.push({ program: policy.asset.program, status: 'unavailable', reason: error instanceof Error ? error.message : String(error) }) + } + } + return results +} diff --git a/packages/shield-swap-cli/src/registry.ts b/packages/shield-swap-cli/src/registry.ts index 1333a3a9..ae6203a9 100644 --- a/packages/shield-swap-cli/src/registry.ts +++ b/packages/shield-swap-cli/src/registry.ts @@ -43,6 +43,10 @@ export const COMMANDS: Record = { summary: 'Private and public holdings per token.', load: () => import('./commands/balances.js'), }, + inventory: { + summary: 'Inspect, plan, and maintain spendable record inventories.', + load: () => import('./commands/inventory.js'), + }, positions: { summary: 'Liquidity positions held + their ranges and fees earned (with option to collect).', load: () => import('./commands/positions.js'), diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index de7733cb..fee65421 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -242,11 +242,20 @@ export async function loadSession(options: { network?: string } = {}) { } const aleo = await loadNetwork(network) + // Open the shared journal only when the caller explicitly selects a database. + const inventoryPath = process.env.VEIL_INVENTORY_DB + const inventoryStore = inventoryPath + ? await (await import('./storage.js')).sqliteRecordInventoryStore(inventoryPath) + : undefined // No prover or scanner URL: both default to the Provable gateway and take the // network from the client, so naming them here would only risk drift. const { walletClient, account } = aleo.createAleoClient({ privateKey: state.privateKey, networkUrl: NETWORK_URL, + // Apply the shared reservations to wallet writes and Shield Swap actions. + recordManagement: inventoryStore ? { + enabled: true, store: inventoryStore, chainId: `aleo:${network}`, + } : undefined, // Faucet-funded accounts hold no public credits; the delegated prover // pays fees from its FeeMaster account. Opt out with // SHIELD_SWAP_FEE_MASTER=0 when the account funds its own fees. diff --git a/packages/shield-swap-cli/src/storage.ts b/packages/shield-swap-cli/src/storage.ts new file mode 100644 index 00000000..44b3864d --- /dev/null +++ b/packages/shield-swap-cli/src/storage.ts @@ -0,0 +1,92 @@ +import { mkdir, open, lstat } from 'node:fs/promises' +import { dirname } from 'node:path' +import { hostname } from 'node:os' +import type { RecordInventoryStore, RecordSpend } from '@provablehq/veil-core' + +/** + * Builds a durable SQLite journal with atomic record reservations across processes. + * Requires Node 22.13 or newer only when called; imports node:sqlite lazily. + * The database contains record nonces and proved transactions, never plaintext records or keys. + * @param path SQLite database file, created with mode 0600; parent directories use 0700. + * @returns A shared store with close() to release its database handle. + * @throws On unsupported Node versions, corrupt data, non-regular files, or database errors. + * @example + * const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') + * const client = createWalletClient({ + * account, transport, proving, + * recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + * }) + */ +export async function sqliteRecordInventoryStore(path: string): Promise { + // Load SQLite only when opening the CLI journal, keeping SDK imports free of + // this Node-specific dependency and its minimum runtime version. + const { DatabaseSync } = await import('node:sqlite') + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + try { const file = await open(path, 'wx', 0o600); await file.close() } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } + // Validate existing files as well as newly created ones; a symlink or broadly + // readable journal must not be accepted as a private transaction store. + const metadata = await lstat(path) + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { + throw new Error('Inventory database must be a regular file with mode 0600') + } + const db = new DatabaseSync(path) + // Wait briefly for competing writers and flush committed checkpoints before + // returning to an execution path that may broadcast immediately afterward. + db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;') + db.exec(`CREATE TABLE IF NOT EXISTS record_spends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, data TEXT NOT NULL, + pid INTEGER NOT NULL, host TEXT NOT NULL + ); CREATE INDEX IF NOT EXISTS record_spends_scope ON record_spends(scope);`) + const host = hostname() + const read = (scope: string): RecordSpend[] => db.prepare('SELECT data FROM record_spends WHERE scope = ? ORDER BY id').all(scope).map((row) => { + const entry = JSON.parse(row.data as string) as RecordSpend + if (!entry.id || entry.scope !== scope || !Array.isArray(entry.records)) throw new Error('Invalid inventory journal entry') + return entry + }) + const transaction = (fn: () => T): T => { + // Take the SQLite write lock before reading reservations so contention + // checks and their inserts remain atomic across CLI processes. + db.exec('BEGIN IMMEDIATE') + try { const result = fn(); db.exec('COMMIT'); return result } + catch (error) { db.exec('ROLLBACK'); throw error } + } + const update = (scope: string, id: string, patch: Parameters[2]) => { + const row = db.prepare('SELECT data FROM record_spends WHERE scope = ? AND id = ?').get(scope, id) + if (!row) throw new Error('Unknown inventory reservation') + const entry = { ...JSON.parse(row.data as string), ...patch } + db.prepare('UPDATE record_spends SET data = ? WHERE scope = ? AND id = ?').run(JSON.stringify(entry), scope, id) + } + const recover = (scope: string) => { + for (const row of db.prepare('SELECT id, data, pid, host FROM record_spends WHERE scope = ?').all(scope)) { + const entry = JSON.parse(row.data as string) as RecordSpend + // A dead local process could not have submitted before persisting prepared. + // Wallet calls are opaque and must never be unlocked using process liveness. + if (entry.status !== 'reserved' || entry.accountType !== 'local' || row.host !== host) continue + try { process.kill(Number(row.pid), 0) } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') update(scope, String(row.id), { status: 'cancelled' }) + } + } + } + return { + list: async (scope) => transaction(() => { recover(scope); return read(scope) }), + acquire: async (entry) => transaction(() => { + // Reclaim only provably unsubmitted local reservations, then acquire all + // requested nonces together or leave the journal unchanged on contention. + recover(entry.scope) + if (new Set(entry.records).size !== entry.records.length) throw new Error('Duplicate record inputs') + // Rejected executions release their inputs, but their private fee was spent. + if (read(entry.scope).some((old) => old.status !== 'cancelled' && + (old.status === 'rejected' ? (old.feeRecordId ? [old.feeRecordId] : []) : old.records) + .some((id) => entry.records.includes(id)))) return undefined + const inserted = db.prepare('INSERT INTO record_spends(scope, data, pid, host) VALUES (?, ?, ?, ?)').run( + entry.scope, JSON.stringify(entry), process.pid, host) + const saved = { ...entry, id: String(inserted.lastInsertRowid) } + db.prepare('UPDATE record_spends SET data = ? WHERE id = ?').run(JSON.stringify(saved), saved.id) + return saved + }), + update: async (scope, id, patch) => transaction(() => update(scope, id, patch)), + close: () => db.close(), + } +} diff --git a/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts b/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts new file mode 100644 index 00000000..b333bf31 --- /dev/null +++ b/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts @@ -0,0 +1,39 @@ +import { createWalletClient, parseRecord } from '@provablehq/veil-core' +import { sqliteRecordInventoryStore } from '../../src/storage.js' +import { executeWithTokenRecord } from '../../../shield-swap/src/actions/swap/internal.js' + +// Each worker scans the same two records. The parent releases both first +// acquisitions together, forcing contention across actual SQLite processes. +const store = await sqliteRecordInventoryStore(process.argv[2]!) +const attempts: string[][] = [] +const acquire = store.acquire +store.acquire = async entry => { + attempts.push(entry.records) + if (attempts.length === 1) { + const proceed = new Promise(resolve => process.once('message', () => resolve())) + process.send!({ ready: true }) + await proceed + } + return acquire(entry) +} +const request = async () => { throw new Error('Unexpected network call') } +const client = createWalletClient({ + account: { type: 'local', source: 'test', address: 'aleo1owner', privateKey: 'test', viewKey: 'test', sign: async () => new Uint8Array(), signMessage: async () => new Uint8Array() }, + transport: { config: { key: 'test', name: 'test', type: 'custom', network: 'testnet', request }, request }, + recordManagement: { enabled: true, store }, + recordProvider: { requestRecords: async () => [1, 2].map(nonce => ({ programName: 'token.aleo', recordName: 'Token', spent: false, tag: `${nonce}field`, + recordPlaintext: `{ owner: aleo1owner.private, amount: 100u128.private, _nonce: ${nonce}group.public }` })), setAccount() {} }, + proving: { mode: 'local', execute: async options => { + const transactionId = `at1${process.pid}` + await options.onProgress?.({ type: 'transaction-confirmed', transactionId }) + return { transactionId, transitions: [], outputs: [parseRecord(options.inputs[0]!).nonce] } + } }, +}) +try { + const result = await executeWithTokenRecord(client, { tokenInProgram: 'token.aleo', tokenId: '1field', minAmount: 10n }, + record => client.executeContract({ program: 'token.aleo', function: 'spend', inputs: [record] })) + process.send!({ nonce: result.outputs[0], attempts }) +} finally { + store.close() + process.disconnect!() +} diff --git a/packages/shield-swap-cli/test/inventory-command.test.ts b/packages/shield-swap-cli/test/inventory-command.test.ts new file mode 100644 index 00000000..d56be102 --- /dev/null +++ b/packages/shield-swap-cli/test/inventory-command.test.ts @@ -0,0 +1,139 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { COMMANDS } from '../src/registry.js' +import { setJsonMode } from '../src/shared.js' +import { loadState } from '../src/session.js' + +// Replace the external signer/scanner boundary; parsing, command dispatch, +// configuration, JSON output, and SQLite reservations remain real. +vi.mock('@provablehq/veil-aleo-sdk', () => ({ loadNetwork: vi.fn(), generateAccount: vi.fn() })) +vi.mock('../src/session.js', async original => ({ ...await original(), loadState: vi.fn() })) +vi.mock('../src/inventory/runner.js', () => ({ runInventoryCycle: vi.fn() })) + +let directory: string, database: string, stdout: string[], options: any[], executions: number +let previousExitCode: typeof process.exitCode +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'shield-inventory-command-')) + database = join(directory, 'inventory.sqlite') + stdout = []; options = []; executions = 0 + previousExitCode = process.exitCode; process.exitCode = undefined + vi.stubEnv('ALEO_PRIVATE_KEY', '') + vi.stubEnv('SHIELD_SWAP_NETWORK', 'testnet') + vi.stubEnv('VEIL_INVENTORY_DB', database) + vi.mocked(loadState).mockImplementation(network => ({ network, privateKey: `state-key-${network}` })) + vi.spyOn(console, 'log').mockImplementation(value => { stdout.push(String(value)) }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process, 'exit').mockImplementation(code => { throw new Error(`EXIT:${code}`) }) + vi.mocked(loadNetwork).mockImplementation(async network => ({ + createRemoteScanner: () => ({}), + createAleoClient: (config: any) => { + options.push({ ...config, network }) + return { account: { address: 'aleo1fixture' }, walletClient: { + getRecordInventory: async ({ asset }: any) => ({ asset, balance: 100n, reserved: [], + available: [{ id: '1group', amount: 100n, record: { recordPlaintext: 'private-record-content' } }] }), + planRecordInventory: async ({ asset, target }: any) => ({ asset, target, steps: [{ kind: 'split' }], inputs: [{ id: '1group', amount: 100n }] }), + } } + }, + }) as any) +}) +afterEach(async () => { + setJsonMode(false); process.exitCode = previousExitCode + vi.restoreAllMocks(); vi.resetAllMocks(); vi.unstubAllEnvs() + await rm(directory, { recursive: true, force: true }) +}) + +async function inventory(argv: string[]) { + setJsonMode(argv.includes('--json')) + expect(COMMANDS.inventory, 'inventory must be reachable from shield-swap').toBeDefined() + await (await COMMANDS.inventory!.load()).main(argv) +} +async function entries() { + const { sqliteRecordInventoryStore } = await import('../src/storage.js') + const store = await sqliteRecordInventoryStore(database) + try { return await store.list(JSON.stringify(['aleo:testnet', 'aleo1fixture'])) } + finally { store.close() } +} +async function execution(stopping = false) { + const { runInventoryCycle } = await import('../src/inventory/runner.js') + vi.mocked(runInventoryCycle).mockImplementation(async (_client, _config, store, scope) => { + executions++ + expect((await store.list(scope)).some(entry => entry.program === '__inventory_manager__' && entry.status === 'reserved')).toBe(true) + await store.acquire({ scope, records: ['spentgroup'], program: 'credits.aleo', function: 'split', status: 'confirmed', createdAt: Date.now(), accountType: 'local' }) + if (stopping) process.emit('SIGTERM') + return [{ program: 'credits.aleo', status: 'complete', result: { status: 'complete', completedSteps: 1, transactionIds: ['at1confirmed'] } }] + }) +} + +describe('shield-swap inventory command', () => { + it('shows help without opening an account or journal', async () => { + await expect(inventory(['--help'])).rejects.toThrow('EXIT:0') + expect(stdout.join('\n')).toContain('inspect|plan|rebalance|run|status') + expect(options).toEqual([]) + }) + it.each(['plan', 'rebalance', 'run'])('%s only plans without --execute', async command => { + await inventory([command, '--asset', 'credits.aleo', '--records', '2', '--json']) + const result = JSON.parse(stdout.join('\n')) + expect(result.plans[0].target.records).toBe(2) + expect(result.plans[0].inputs[0].amount).toBe('100') + expect(result.network).toBe('testnet') + expect(options[0].privateKey).toBe('state-key-testnet') + expect(options[0].recordManagement).toMatchObject({ enabled: true, chainId: 'aleo:testnet' }) + expect(executions).toBe(0) + expect(await entries()).toEqual([]) + }) + it('inspects multiple assets in one JSON object without exposing plaintext', async () => { + const config = join(directory, 'policies.json') + await writeFile(config, JSON.stringify({ policies: ['first.aleo', 'second.aleo'].map(program => ({ asset: { program }, target: { records: 2 } })) })) + await inventory(['inspect', '--config', config, '--json']) + expect(JSON.parse(stdout.join('\n')).inventories.map((item: any) => item.asset.program)).toEqual(['first.aleo', 'second.aleo']) + expect(stdout.join('\n')).not.toContain('private-record-content') + }) + it('honors explicit network, database, field, and environment-key overrides', async () => { + vi.stubEnv('ALEO_PRIVATE_KEY', 'environment-key') + await inventory(['plan', '--asset', 'points.aleo', '--record-name', 'Balance', '--amount-field', 'points', '--network', 'mainnet', '--database', join(directory, 'other.sqlite'), '--json']) + expect(options[0]).toMatchObject({ network: 'mainnet', privateKey: 'environment-key', recordManagement: { chainId: 'aleo:mainnet' } }) + expect(JSON.parse(stdout.join('\n')).plans[0].asset).toMatchObject({ program: 'points.aleo', recordName: 'Balance', amountField: 'points' }) + }) + it('executes maintenance only with --execute and releases only the manager lease', async () => { + await execution() + await inventory(['rebalance', '--asset', 'credits.aleo', '--records', '2', '--execute', '--json']) + expect(JSON.parse(stdout.join('\n')).results[0].result.transactionIds).toEqual(['at1confirmed']) + expect(executions).toBe(1) + expect((await entries()).map(entry => [entry.program, entry.status])).toEqual([ + ['__inventory_manager__', 'cancelled'], ['credits.aleo', 'confirmed'], + ]) + }) + it('prints one JSON summary when a continuous run stops', async () => { + await execution(true) + await inventory(['run', '--asset', 'credits.aleo', '--execute', '--json']) + expect(JSON.parse(stdout.join('\n'))).toMatchObject({ cycles: 1, results: [{ status: 'complete' }] }) + expect((await entries())[0].status).toBe('cancelled') + }) + it('reports status without exposing saved transaction payloads', async () => { + const { sqliteRecordInventoryStore } = await import('../src/storage.js') + const store = await sqliteRecordInventoryStore(database) + await store.acquire({ scope: JSON.stringify(['aleo:testnet', 'aleo1fixture']), records: ['heldgroup'], program: 'token.aleo', function: 'join', status: 'prepared', createdAt: Date.now(), accountType: 'local', transactionId: 'at1saved', rebroadcastAttempts: 2, lastRebroadcastError: 'Serial number already spent', + transaction: { id: 'at1saved', proof: 'stored-transaction-body' } as any }) + store.close() + await inventory(['status', '--json']) + expect(JSON.parse(stdout.join('\n')).operations[0]).toMatchObject({ status: 'prepared', transactionId: 'at1saved', + rebroadcastAttempts: 2, lastRebroadcastError: 'Serial number already spent' }) + expect(stdout.join('\n')).not.toContain('stored-transaction-body') + expect(executions).toBe(0) + }) + it('rejects unknown flags before account initialization and returns JSON errors', async () => { + await expect(inventory(['rebalance', '--recrods', '2', '--execute', '--json'])).rejects.toThrow('EXIT:64') + expect(JSON.parse(stdout.join('\n')).error.message).toContain('--recrods') + expect(options).toEqual([]) + }) + it('reports missing credentials without generating an account', async () => { + vi.mocked(loadState).mockReturnValue({ network: 'testnet' }) + await inventory(['inspect', '--asset', 'credits.aleo', '--json']) + expect(JSON.parse(stdout.join('\n')).error.message).toContain('shield-swap setup') + expect(process.exitCode).toBe(1) + expect(options).toEqual([]) + }) +}) diff --git a/packages/shield-swap-cli/test/inventory-runner.test.ts b/packages/shield-swap-cli/test/inventory-runner.test.ts new file mode 100644 index 00000000..843211e8 --- /dev/null +++ b/packages/shield-swap-cli/test/inventory-runner.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { parseInventoryConfig } from '../src/inventory/config.js' +import { runInventoryCycle } from '../src/inventory/runner.js' +import { memoryRecordInventoryStore, getRecordInventory, planRecordInventory, rebalanceRecordInventory, reconcileRecordInventory } from '@provablehq/veil-core' +vi.mock('@provablehq/veil-core', async (load) => ({ ...await load(), + getRecordInventory: vi.fn(), planRecordInventory: vi.fn(), rebalanceRecordInventory: vi.fn(), reconcileRecordInventory: vi.fn() })) +const policy = { asset: { program: 'credits.aleo', standard: 'credits' }, target: { records: 4, minRecordAmount: '100' } } +beforeEach(() => { vi.resetAllMocks(); vi.mocked(reconcileRecordInventory).mockResolvedValue([]) }) + +describe('inventory application policies', () => { + it('uses automatic helper selection and supports native or bounded custom joins', () => { + expect(parseInventoryConfig({}).tokenJoin).toBeUndefined() + expect(parseInventoryConfig({ network: 'mainnet' }).tokenJoin).toBeUndefined() + expect(parseInventoryConfig({ tokenJoin: false }).tokenJoin).toBe(false) + expect(parseInventoryConfig({ tokenJoin: { program: 'custom_router.aleo', maxRecords: 4 } }).tokenJoin).toMatchObject({ maxRecords: 4 }) + for (const tokenJoin of [true, { program: 'bad' }, { program: 'router.aleo', maxRecords: 1 }, { program: 'router.aleo', maxRecords: 16 }]) { + expect(() => parseInventoryConfig({ tokenJoin })).toThrow() + } + }) + it('accepts program-only assets and explicit record/field overrides', () => { + for (const asset of [{ program: 'custom.aleo' }, { program: 'custom.aleo', recordName: 'Balance', amountField: 'quantity' }]) { + expect(parseInventoryConfig({ policies: [{ asset, target: { records: 2 } }] }).policies[0]?.asset).toEqual(asset) + } + for (const asset of [{ program: 'custom.aleo', recordName: 'bad-name' }, { program: 'custom.aleo', amountField: 'bad field' }]) { + expect(() => parseInventoryConfig({ policies: [{ asset, target: { records: 2 } }] })).toThrow() + } + }) + it('applies cooldowns to the underlying token of a routed join', async () => { + const store = memoryRecordInventoryStore() + await store.acquire({ scope: 'scope', records: ['1group'], program: 'main_aj_arc20_2_15.aleo', + assetProgram: 'token.aleo', function: 'join_3', status: 'confirmed', accountType: 'local', createdAt: Date.now() }) + const config = parseInventoryConfig({ policies: [{ asset: { program: 'token.aleo', standard: 'arc20' }, target: { records: 1 } }] }) + expect(await runInventoryCycle({} as any, config, store, 'scope')).toEqual([{ program: 'token.aleo', status: 'cooldown' }]) + expect(getRecordInventory).not.toHaveBeenCalled() + }) + it('validates precision, networks, duplicates, intervals, and hysteresis', () => { + expect(parseInventoryConfig({ policies: [policy] }).policies[0]?.target.minRecordAmount).toBe(100n) + expect(() => parseInventoryConfig({ policies: [{ ...policy, target: { records: 4, minRecordAmount: 100 } }] })).toThrow('decimal strings') + expect(() => parseInventoryConfig({ network: 'devnet' })).toThrow('Network') + expect(() => parseInventoryConfig({ intervalMs: 0 })).toThrow() + expect(() => parseInventoryConfig({ policies: [policy, policy] })).toThrow('Duplicate') + expect(() => parseInventoryConfig({ policies: [{ ...policy, countRange: [5, 7] }] })).toThrow('countRange') + expect(() => parseInventoryConfig({ privateKey: 'do-not-store' })).toThrow('privateKeyEnv') + }) + it('waits for trading reservations instead of planning competing work', async () => { + vi.mocked(reconcileRecordInventory).mockResolvedValue([{ program: 'swap.aleo', status: 'submitted' }] as any) + const results = await runInventoryCycle({} as any, parseInventoryConfig({ policies: [policy] }), memoryRecordInventoryStore(), 'scope') + expect(results[0]?.status).toBe('waiting') + expect(getRecordInventory).not.toHaveBeenCalled() + }) + it('reports a failed rebroadcast as waiting without planning any asset', async () => { + vi.mocked(reconcileRecordInventory).mockResolvedValue([{ program: 'token.aleo', status: 'prepared', + transactionId: 'at1stuck', rebroadcastAttempts: 2, lastRebroadcastError: 'Serial number already spent' }] as any) + const config = parseInventoryConfig({ policies: [policy, { ...policy, asset: { program: 'token.aleo' } }] }) + expect(await runInventoryCycle({} as any, config, memoryRecordInventoryStore(), 'scope')).toEqual([{ + program: '*', status: 'waiting', + reason: 'Unresolved transactions retain their reservations; rebroadcast of at1stuck failed: Serial number already spent', + }]) + expect(getRecordInventory).not.toHaveBeenCalled() + expect(planRecordInventory).not.toHaveBeenCalled() + expect(rebalanceRecordInventory).not.toHaveBeenCalled() + }) + it('uses the acceptable count range without reshaping every trade', async () => { + vi.mocked(getRecordInventory).mockResolvedValue({ available: Array.from({ length: 3 }, () => ({ amount: 100n })), reserved: [], balance: 300n } as any) + const result = await runInventoryCycle({} as any, parseInventoryConfig({ policies: [{ ...policy, countRange: [3, 6] }] }), memoryRecordInventoryStore(), 'scope') + expect(result[0]?.status).toBe('satisfied') + expect(planRecordInventory).not.toHaveBeenCalled() + }) + it('includes intrinsic credits deductions in the rolling fee budget', async () => { + vi.mocked(getRecordInventory).mockResolvedValue({ asset: { program: 'credits.aleo', recordName: 'credits', amountField: 'microcredits', amountType: 'u64' }, available: [], reserved: [], balance: 100n }) + vi.mocked(planRecordInventory).mockResolvedValue({ steps: [{}], deduction: 30_000n } as any) + const result = await runInventoryCycle({} as any, parseInventoryConfig({ policies: [policy], maxDailyFeeMicrocredits: '29999' }), memoryRecordInventoryStore(), 'scope') + expect(result[0]?.status).toBe('budget') + expect(rebalanceRecordInventory).not.toHaveBeenCalled() + }) + it('stops the cycle after an uncertain submission', async () => { + vi.mocked(getRecordInventory).mockResolvedValue({ asset: { program: 'credits.aleo', recordName: 'credits', amountField: 'microcredits', amountType: 'u64' }, available: [], reserved: [], balance: 100n }) + vi.mocked(planRecordInventory).mockResolvedValue({ steps: [{}], deduction: 0n } as any) + vi.mocked(rebalanceRecordInventory).mockResolvedValue({ status: 'interrupted', completedSteps: 0, transactionIds: ['at1unknown'] }) + const config = parseInventoryConfig({ policies: [policy, { ...policy, asset: { program: 'token.aleo', standard: 'arc20' } }] }) + expect(await runInventoryCycle({} as any, config, memoryRecordInventoryStore(), 'scope')).toHaveLength(1) + expect(rebalanceRecordInventory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/shield-swap-cli/test/inventory-storage.test.ts b/packages/shield-swap-cli/test/inventory-storage.test.ts new file mode 100644 index 00000000..3dd2d60e --- /dev/null +++ b/packages/shield-swap-cli/test/inventory-storage.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { execFile, fork } from 'node:child_process' +import { promisify } from 'node:util' +import { sqliteRecordInventoryStore } from '../src/storage.js' +import type { RecordSpend } from '@provablehq/veil-core' + +const exec = promisify(execFile) +const directories: string[] = [] +const entry: Omit = { scope: 'testnet/account', records: ['1group'], program: 'credits.aleo', function: 'join', status: 'reserved', createdAt: Date.now(), accountType: 'local' } +async function path() { const dir = await mkdtemp(join(tmpdir(), 'veil-inventory-')); directories.push(dir); return join(dir, 'journal.sqlite') } +afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) +const moduleUrl = new URL('../src/storage.ts', import.meta.url).href +const child = async (database: string, body: string) => exec(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `import { sqliteRecordInventoryStore } from ${JSON.stringify(moduleUrl)}; const store = await sqliteRecordInventoryStore(${JSON.stringify(database)}); ${body}; store.close();`], { cwd: resolve('.') }) + +describe('CLI-only durable inventory store', () => { + it('reserves atomically across independent database connections and isolates accounts', async () => { + const database = await path() + const a = await sqliteRecordInventoryStore(database) + const b = await sqliteRecordInventoryStore(database) + try { + const results = await Promise.all([a.acquire(entry), b.acquire(entry)]) + expect(results.filter(Boolean)).toHaveLength(1) + expect(await b.acquire({ ...entry, scope: 'mainnet/account' })).toBeDefined() + expect((await stat(database)).mode & 0o777).toBe(0o600) + await a.update(entry.scope, results.find(Boolean)!.id, { status: 'confirmed' }) + expect(await b.acquire(entry)).toBeUndefined() // A stale scan must not resurrect spent inputs. + } finally { a.close(); b.close() } + }) + it('serializes acquisition from separate processes', async () => { + const database = await path() + const store = await sqliteRecordInventoryStore(database); store.close() + const body = `const entry = await store.acquire(${JSON.stringify({ ...entry, accountType: 'rpc' })}); console.log(entry ? 'acquired' : 'busy')` + const results = await Promise.all([child(database, body), child(database, body)]) + expect(results.map((result) => result.stdout.trim()).sort()).toEqual(['acquired', 'busy']) + }) + it('reselects distinct swap inputs after contention between separate processes', async () => { + const database = await path() + const store = await sqliteRecordInventoryStore(database) + const workers = [0, 1].map(() => fork(new URL('./fixtures/concurrentInventoryWorker.ts', import.meta.url), [database], { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + })) + const exits = workers.map(worker => new Promise(resolve => worker.once('exit', () => resolve()))) + try { + let ready = 0 + const results = await Promise.all(workers.map(worker => new Promise<{ nonce: string; attempts: string[][] }>((resolve, reject) => { + let stderr = '' + worker.stderr!.on('data', chunk => { stderr += String(chunk) }) + worker.once('error', reject) + worker.once('exit', code => { if (code !== 0) reject(new Error(`Inventory worker exited ${code}: ${stderr}`)) }) + worker.on('message', (message: any) => { + if (message.ready) { + if (++ready === workers.length) for (const peer of workers) peer.send('acquire') + } else resolve(message) + }) + }))) + expect(results.map(result => result.nonce).sort()).toEqual(['1group', '2group']) + expect(results.map(result => result.attempts[0])).toEqual([['1group'], ['1group']]) + expect(results.map(result => result.attempts.length).sort()).toEqual([1, 2]) + expect((await store.list(JSON.stringify(['testnet', 'aleo1owner']))).map(entry => entry.status)).toEqual(['confirmed', 'confirmed']) + } finally { + for (const worker of workers) if (worker.exitCode === null) worker.kill() + await Promise.all(exits) + store.close() + } + }, 15_000) + it('releases abandoned local pre-proving reservations but retains prepared transactions', async () => { + const database = await path() + await child(database, `await store.acquire(${JSON.stringify(entry)})`) + let store = await sqliteRecordInventoryStore(database) + expect((await store.list(entry.scope))[0]?.status).toBe('cancelled') + store.close() + await child(database, `const e = await store.acquire(${JSON.stringify(entry)}); await store.update(e.scope, e.id, { status: 'prepared', transactionId: 'at1saved', transaction: { id: 'at1saved', type: 'execute' } })`) + store = await sqliteRecordInventoryStore(database) + try { + expect((await store.list(entry.scope)).at(-1)).toMatchObject({ status: 'prepared', transactionId: 'at1saved', transaction: { id: 'at1saved' } }) + expect(await store.acquire(entry)).toBeUndefined() + } finally { store.close() } + }) + it('retains only the private fee record after rejected execution across restart', async () => { + const database = await path() + let store = await sqliteRecordInventoryStore(database) + const reserved = await store.acquire({ ...entry, records: ['1group', '2group'], feeRecordId: '2group' }) + await store.update(entry.scope, reserved!.id, { status: 'rejected' }) + store.close() + store = await sqliteRecordInventoryStore(database) + try { + expect(await store.acquire({ ...entry, records: ['2group'] })).toBeUndefined() + expect(await store.acquire(entry)).toBeDefined() + } finally { store.close() } + }) + it('persists recovery diagnostics across restarts and clears a resolved error', async () => { + const database = await path() + let store = await sqliteRecordInventoryStore(database) + const saved = (await store.acquire({ ...entry, status: 'prepared', transactionId: 'at1saved', + transaction: { id: 'at1saved', type: 'execute' } as any }))! + expect(saved.rebroadcastAttempts).toBeUndefined() // Existing journal rows need no migration. + await store.update(entry.scope, saved.id, { rebroadcastAttempts: 1, lastRebroadcastError: 'Network unavailable' }) + store.close() + store = await sqliteRecordInventoryStore(database) + try { + expect((await store.list(entry.scope))[0]).toMatchObject({ status: 'prepared', transactionId: 'at1saved', + rebroadcastAttempts: 1, lastRebroadcastError: 'Network unavailable' }) + expect(await store.acquire(entry)).toBeUndefined() + await store.update(entry.scope, saved.id, { status: 'submitted', rebroadcastAttempts: 2, lastRebroadcastError: undefined }) + } finally { store.close() } + store = await sqliteRecordInventoryStore(database) + try { + const [recovered] = await store.list(entry.scope) + expect(recovered).toMatchObject({ status: 'submitted', rebroadcastAttempts: 2, transaction: saved.transaction }) + expect(recovered!.lastRebroadcastError).toBeUndefined() + expect(await store.acquire(entry)).toBeUndefined() + } finally { store.close() } + }) + it('retains an opaque wallet reservation after its process exits', async () => { + const database = await path() + await child(database, `await store.acquire(${JSON.stringify({ ...entry, accountType: 'rpc' })})`) + const store = await sqliteRecordInventoryStore(database) + try { expect((await store.list(entry.scope))[0]?.status).toBe('reserved') } + finally { store.close() } + }) +}) diff --git a/packages/shield-swap-cli/test/package-smoke.mjs b/packages/shield-swap-cli/test/package-smoke.mjs new file mode 100644 index 00000000..3f2ec46f --- /dev/null +++ b/packages/shield-swap-cli/test/package-smoke.mjs @@ -0,0 +1,39 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const exec = promisify(execFile) +const binary = fileURLToPath(new URL('../dist/index.js', import.meta.url)) + +test('built binary lists inventory and loads its help', async () => { + const top = await exec(process.execPath, [binary, '--help']) + assert.match(top.stdout, /inventory/) + const child = await exec(process.execPath, [binary, 'inventory', '--help']) + assert.match(child.stdout, /inspect\|plan\|rebalance\|run\|status/) +}) + +test('published storage entry point opens and reopens a durable journal', async () => { + // Resolve through package exports and execute the bundle. Source-level tests + // cannot catch a bundler rewriting node:sqlite to an npm package import. + const { sqliteRecordInventoryStore } = await import('@provablehq/shield-swap-cli/storage') + const directory = await mkdtemp(join(tmpdir(), 'shield-inventory-package-')) + let store + try { + const database = join(directory, 'inventory.sqlite') + store = await sqliteRecordInventoryStore(database) + const saved = await store.acquire({ scope: 'package-smoke', records: ['nonce'], program: 'credits.aleo', + function: 'join', status: 'prepared', createdAt: Date.now(), accountType: 'local' }) + store.close() + store = await sqliteRecordInventoryStore(database) + assert.equal((await store.list('package-smoke'))[0].id, saved.id) + assert.equal(await store.acquire({ ...saved, id: undefined }), undefined) + } finally { + store?.close() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/shield-swap-cli/tsup.config.ts b/packages/shield-swap-cli/tsup.config.ts index bec54c0e..3afa91df 100644 --- a/packages/shield-swap-cli/tsup.config.ts +++ b/packages/shield-swap-cli/tsup.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/session.ts'], + entry: ['src/index.ts', 'src/session.ts', 'src/storage.ts'], format: ['esm'], + // node:sqlite has no bare 'sqlite' alias; retain the protocol in built imports. + removeNodeProtocol: false, dts: true, clean: true, sourcemap: true, diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index 2d04005b..9b0e3004 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -63,6 +63,11 @@ account bootstrap, pool reads, quoting, balances, swap history, swaps, minting a position, rebalancing it to a new range, and taking liquidity back out. Each one is a single file that reads top to bottom. +For concurrent trades, run the [concurrent-swaps example](../../examples/shield-swap/concurrent-swaps.ts). +It previews a six-record inventory, then submits six swaps and claims their +outputs when called with `--execute`. The [example guide](../../examples/shield-swap/README.md) +covers setup, shared reservations, and recovery after an interruption. + [Open them in StackBlitz](https://stackblitz.com/github/ProvableHQ/veil/tree/main/examples/shield-swap) to browse the set in an editor. The pool and token reads run there as they are; anything that signs needs credentials, and a private key does not belong in a @@ -560,45 +565,126 @@ node never had from one it simply had not confirmed yet. ### Concurrent swaps -Every swap is bound to a blinded identity — a one-time address derived from your -view key and a counter — and the program asserts each blinded address is used only -once. Derived on demand, that is safe in sequence and unsafe in parallel: two -swaps started together scan the chain, both see the same counter unused, and the -second reverts on finalize after the first consumes it. Nothing surfaces locally, -because at proving time the address genuinely was unused. +Private swaps can sell the same token concurrently when each trade has a +separate sufficient input record and a separate blinded identity. Enable record +management on the wallet client, then attach `shieldSwapActions`. Use persistent +stores to retain reservations and claim handles after a restart. -The swap actions handle this for you. `swap` and `swapMultiHop` reserve the -identity before submitting and record the resulting handle after, and -`claimSwapOutput` marks it claimed. A composed client gets an in-memory store by -default, so the two swaps below cannot collide even with no configuration — but -that store dies with the process, so name a file one for anything long-running: +The following setup is for one Node process and a funded testnet account with +DEX access. The optional SQLite adapter comes from `@provablehq/shield-swap-cli` +and requires Node 22.13+. Install it separately from the SDK, or provide a custom +`RecordInventoryStore` as described in the +[record management guide](../../site/docs/guides/record-inventory.md). ```ts +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { shieldSwapActions, parseUnits } from '@provablehq/shield-swap-sdk' import { swapFileStore } from '@provablehq/shield-swap-sdk/node' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' -const client = walletClient.extend( - shieldSwapActions({ api: {}, blindedIdentities: swapFileStore('.veil/blinded.json') }), -) +const privateKey = process.env.ALEO_PRIVATE_KEY +if (!privateKey) throw new Error('Set ALEO_PRIVATE_KEY') +const aleo = await loadNetwork('testnet') +const address = aleo.privateKeyToAccount(privateKey).address +const directory = `.veil/testnet/${address}` +const store = await sqliteRecordInventoryStore(`${directory}/inventory.sqlite`) +const identities = swapFileStore(`${directory}/blinded.json`) +const { walletClient } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + provingMode: 'delegated', useFeeMaster: true, confirmationTimeout: 600_000, + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, +}) +const client = walletClient.extend(shieldSwapActions({ + api: {}, blindedIdentities: identities, +})) +await client.authenticateShieldSwap() +``` + +Before each batch, reconcile unfinished transactions and prepare enough records: -// Nothing else to do — these two cannot collide on an identity. -const [a, b] = await Promise.all([ - client.swap({ poolKey: poolA, tokenInId: usdc, amountIn, imports }), - client.swap({ poolKey: poolB, tokenInId: eth, amountIn, imports }), -]) +```ts +const journal = await client.reconcileRecordInventory() +if (journal.some((entry) => !['confirmed', 'rejected', 'cancelled'].includes(entry.status))) { + throw new Error('Resolve pending transactions before starting another batch') +} +const pending = await client.getUnclaimedSwaps() +if (pending.swaps.length || pending.unresolvable.length) { + throw new Error('Recover previous swaps before starting another batch') +} +const token = await client.tokenData('USDCx') +const plan = await client.planInventory({ + token: token.id, + target: { + records: 6, minRecordAmount: parseUnits('0.1', token.decimals), + distribution: 'balanced', toleranceBps: 0, + }, + maxTransactions: 24, +}) +console.table(plan.steps) // Inspect the joins and splits before execution. ``` -`swapFileStore` is an alias of `fileBlindedIdentityStore`; the existing export -and saved file format remain supported. +The plan covers the entire available USDCx inventory. Inspect it before running +the following writes. `rebalanceRecordInventory` waits for each confirmed output +to appear in the scanner; an interruption stops the batch before trading. + +```ts +const maintenance = await client.rebalanceRecordInventory({ + plan, timeoutMs: 600_000, pollIntervalMs: 3_000, +}) +if (maintenance.status !== 'complete') { + throw new Error(`Inventory preparation interrupted: ${maintenance.reason}`) +} -Reservations serialize, so each swap gets its own counter, and each is written -before its transaction is submitted — which is what keeps an unconfirmed swap from -having its counter handed out again. +// Fetch a fresh quote after the maintenance transactions have finished. +const quote = await client.quote({ from: 'USDCx', to: 'ETH', amountIn: '0.1', slippageBps: 50 }) +const swaps = await Promise.allSettled( + Array.from({ length: 6 }, () => client.swap({ quote })), +) +const handles = swaps.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + +// Attempt every successful swap's claim even when another trade failed. +const claims = await Promise.allSettled(handles.map(async (handle) => { + await client.waitForSwapOutput({ handle, timeout: 600_000, pollingInterval: 3_000 }) + return client.claimSwapOutput({ handle }) +})) +console.log('Swap results:', swaps.map((result) => result.status)) +console.table(claims.map((result, index) => ({ + swap: handles[index]!.transactionId, + status: result.status, + amountOut: result.status === 'fulfilled' ? result.value.amountOut.toString() : undefined, + refund: result.status === 'fulfilled' ? result.value.amountRemaining.toString() : undefined, +}))) +// Retain both stores after failures. Close SQLite after all calls have settled. +store.close() +``` -What the default in-memory store does not give you is persistence: a restart -rescans the chain for its next counter, and forgets any swap it had not yet -claimed. The on-disk store keeps both. Two processes sharing one account need one -store between them either way — the chain read alone cannot close that window, -because the check and the submission are not atomic. +The main constraints are: + +- With enabled coordination, local automatic selection tries another sufficient + record after a reservation conflict, once per candidate. It preserves the + quote and never retries proving or an uncertain submission. An explicit + `tokenRecord` stays fixed; swaps do not auto-join insufficient records. +- Every participating client must share the record store and chain identity. + Blinded identities need the same store object within one process. SQLite + reservations are safe across processes, but sharing `blinded.json` does not + coordinate identity counters. Use one process with the bundled identity store. +- Each trade enforces its own output floor. Pool prices can move during the + batch, so inspect every swap and claim result. `claims[i]` corresponds to + `handles[i]`; keep failed claims for recovery and account for input refunds. +- A timeout does not prove rejection. Reopen both stores, call + `client.reconcileRecordInventory()` and `client.getUnclaimedSwaps()`, and resolve + uncertain work before starting another batch. Wallet signers must supply + pinned token records; automatic reselection applies to local signers. + +The [concurrent swaps walkthrough](../../site/docs/guides/shield-swap.md#concurrent-swaps) +explains setup and failure handling in detail. The +[runnable example](../../examples/shield-swap/concurrent-swaps.ts) defaults to a +preview and adds restart guards; see its [usage guide](../../examples/shield-swap/README.md). +The CLI's `swap-concurrent` command still requires a different input token in +each leg. The SDK example supports repeated trades of the same token. + +#### Track and recover swap identities To opt a single call out of tracking, pass `blindedIdentities: undefined`; the identity is then derived by scanning the chain and nothing is written. The @@ -1202,6 +1288,27 @@ fixture format. Both route shapes are validated before any funds are spent. A deployment whose API always selects direct routes needs a separate multi-hop fixture topology; the test fails rather than silently skipping that coverage. +## Record management + +The inventory wrappers pass each token's underlying program to core, which +resolves its record and amount field. For more than two records, core prefers +available batch helpers: dedicated credits and stablecoin helpers accept up to +16 records; dynamic ARC helpers accept up to 15. Set +`recordManagement.tokenJoin: false` for native pairwise joins, or supply +`{ program, maxRecords? }` to override the dynamic ARC router. + +Reservations and recovery are off by default. Set +`recordManagement: { enabled: true, store, chainId }` when creating the wallet +client, then apply `shieldSwapActions` to prevent maintenance and trading from +spending the same records. Adding `recordActions()` alone leaves these features +disabled. `planInventory({ token, target })` and +`rebalanceInventory({ token, target })` use the DEX token's underlying program +with core's ARC-20/ARC-22/credits actions. Participating clients MUST share the +same store and chain identity. The SDK has no database dependency. + +Follow the [record management guide](../../site/docs/guides/record-inventory.md) +for configuration, shared storage, and recovery after an interruption. + ## Compliance diff --git a/packages/shield-swap/skills/SKILL.md b/packages/shield-swap/skills/SKILL.md index 5ab4cce7..0edbf932 100644 --- a/packages/shield-swap/skills/SKILL.md +++ b/packages/shield-swap/skills/SKILL.md @@ -212,11 +212,12 @@ funded account). (remote proving + confirmation). Set timeouts accordingly and never re-submit just because a call is slow — check the state file and chain first. -- **Concurrent swaps need disjoint input records.** Blinded identities are - handled for you: every swap reserves one from the store `loadSession` - configures, and reservations serialize, so two swaps cannot collide on an - identity. Records are still yours to keep apart — two swaps selling the same - token can pick the same record and double-spend it. +- **Concurrent swaps need disjoint input records.** Enable record management + to let local swaps select and reserve different covering records automatically. + Same-token swaps need enough separate records; a balance spread across smaller + records cannot cover them. Coordination stays off by default. With it disabled, + pass disjoint records or use different input tokens. Keep one shared blinded + identity store instance for concurrent swaps within a process. - **One runbook script at a time.** The state file has no lock, though there is little left in it to race over: swap handles belong to the identity store, which serializes its own writes, and positions are discovered from chain diff --git a/packages/shield-swap/skills/swapping.md b/packages/shield-swap/skills/swapping.md index d33f978e..69f304ba 100644 --- a/packages/shield-swap/skills/swapping.md +++ b/packages/shield-swap/skills/swapping.md @@ -169,33 +169,23 @@ per distinct held token that has a live pool. Before running anything: 3. Ask how many — and which — swaps they want, up to that maximum. Only then execute. -Two per-account resources collide under concurrency; both must be -partitioned explicitly: - -1. **Blinded-identity counters.** Each swap consumes a single-use blinded - address derived from a counter. `swap()` discovers the next unused - counter by scanning the chain — concurrent swaps see the same chain and - pick the SAME counter, and all but one gets rejected. Fix: reserve a - counter block up front and pass each swap its own explicit identity. -2. **Input records.** Record selection picks the one covering record per - token — concurrent swaps selling the SAME token grab the same record and - all but one fails as a double-spend. Fix: give each concurrent swap a - different input token. (Same-token bursts require record splitting; - prefer different tokens or sequential submission instead.) +The CLI keeps its one-trade-per-input-token limit. An SDK script can submit +several trades selling the same token when `recordManagement.enabled` is true +and enough separate records cover the trades. In a CLI session, setting +`VEIL_INVENTORY_DB` enables coordination with the selected SQLite journal. + +Each swap reserves a blinded identity from the session's shared store. Record +selection tries the next eligible candidate if another operation reserves the +first one before proving. Each candidate is tried once; explicit records and +execution failures are not retried. Keep concurrent calls in one process: the +bundled identity store does not coordinate counters between processes. ```ts -// swaps[] entries MUST each sell a different token (disjoint records). -// Identities need no handling here: each call reserves its own from the store, -// and reservations serialize, so two swaps cannot derive the same blinded -// address and have the second revert on the uniqueness assert. +// With coordination enabled, same-token trades need one covering record each. +// Otherwise every entry must use a different input token or explicit record. const results = await Promise.allSettled(swaps.map((s) => client.swap(s))) ``` -Without a configured store this is the one flow that breaks: both calls would -scan the chain, find the same free counter, and the second would revert on -finalize having paid its fee. `loadSession` configures one, so this is safe by -default — see the SDK README's "Concurrent swaps" section for the mechanism. - `Promise.all` rejects on the first failure but the other swaps keep running server-side — always sweep with `client.getUnclaimedSwaps()` afterwards and claim everything that confirmed, @@ -242,8 +232,8 @@ handles carry `poolKeys`) and claims each with the right action. | `requires auth` / 401 | Session missing or expired | `loadSession()` authenticates; it auto-renews. Re-run the script. | | 403 `redeem an invite code` | Access gate | Back to [startup.md](./startup.md) — redeem a code. | | No covering record for `amountIn` | Fragmented/small records | Lower `amountIn`, or airdrop again if truly empty. | -| Duplicate blinded address rejection | Concurrent swaps raced the counter scan | Use the explicit-identity recipe above. | -| Double-spend rejection | Two swaps selected the same record | Different input tokens per concurrent swap. | +| Duplicate blinded address rejection | Concurrent swaps raced the counter scan | Share one identity store instance within the process, or supply distinct explicit identities. | +| Double-spend rejection | Swaps bypassed record coordination and selected the same record | Enable coordination with a shared store, or supply disjoint records. | | `/route` 404 `no executable route … for the requested amount` | Quote unavailable at that size | Proceed without `expectedOut` (spot floor applies), or resize the trade. | | `amount_out_min` revert | Price moved past slippage | Re-quote, widen `slippageBps` modestly, retry. | | Swap confirmed but output missing | Not finalized yet | Normal — claim with the retry loop in [collecting.md](./collecting.md). | diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts new file mode 100644 index 00000000..c48beef2 --- /dev/null +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -0,0 +1,53 @@ +import { planRecordInventory, type Client, type InventoryPlan, type InventoryTarget } from '@provablehq/veil-core' +import type { ApiClient } from '../../api/client.js' +import { tokenData } from '../../utils/tokens.js' + +/** + * Defines the DEX token and desired record count and distribution. + * @property token Token symbol or DEX token id. + * @property target Desired count, minimum amount and distribution in underlying base units. + * @property api Token metadata API; the decorator supplies its configured API. + * @property maxTransactions Maximum planned operations, defaults to 100. + */ +export type PlanInventoryParameters = { token: string; target: InventoryTarget; api: ApiClient; maxTransactions?: number } + +/** + * Plans inventory for a DEX token's underlying asset, using core record tooling. + * Resolves metadata and validates the deployed token interface; never signs. + * @param client Account client; enabled record management excludes shared reservations. + * @param params Token, target, API and optional operation limit. + * @returns A core inventory plan for the underlying program, not the AMM wrapper. + * @throws When token metadata or its supported underlying program cannot be resolved. + * @example + * import type { WalletClient } from '@provablehq/veil-core' + * import { parseUnits, type ShieldSwapActions } from '@provablehq/shield-swap-sdk' + * + * async function previewUsdcxInventory(client: WalletClient & ShieldSwapActions) { + * // Extend a wallet client with shieldSwapActions({ api: {} }) first. + * const token = await client.tokenData('USDCx') + * const plan = await client.planInventory({ + * token: token.id, + * target: { + * records: 6, + * minRecordAmount: parseUnits('0.1', token.decimals), + * distribution: 'balanced', + * toleranceBps: 0, + * }, + * maxTransactions: 24, + * }) + * // The plan names the spendable underlying program, not its AMM wrapper. + * console.log('Asset:', plan.asset.program, 'Operations:', plan.steps.length) + * // After review, call client.rebalanceRecordInventory({ plan }). + * return plan + * } + */ +export async function planInventory(client: Client, params: PlanInventoryParameters): Promise { + const token = await tokenData(params.api, params.token) + // Maintenance operates on the spendable underlying token, not the AMM + // wrapper program used when routing a swap. + const program = token.underlyingProgram + if (!program) throw new Error(`Token ${token.symbol} has no underlying record program`) + // Core resolves record and amount defaults from the underlying program and + // selects its available join helper when several records need consolidation. + return planRecordInventory(client, { asset: { program }, target: params.target, maxTransactions: params.maxTransactions }) +} diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts new file mode 100644 index 00000000..87a965cd --- /dev/null +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -0,0 +1,51 @@ +import { rebalanceRecordInventory, type Client, type InventoryResult, type RebalanceRecordInventoryParameters } from '@provablehq/veil-core' +import { planInventory, type PlanInventoryParameters } from './planInventory.js' + +/** Combines DEX token resolution with core inventory execution limits. */ +export type RebalanceInventoryParameters = PlanInventoryParameters & Omit + +/** + * Rebalances a DEX token's underlying private inventory through core join/split actions. + * Scans, plans, signs and waits for each dependent transition using shared reservations. + * @param client Account client with recordManagement.enabled for coordinated inventory execution. + * @param params Token, target, API, and bounded execution options. + * @returns Completed progress or a resumable interruption. + * @throws On unsupported assets or invalid targets before submission. + * @example + * import type { WalletClient } from '@provablehq/veil-core' + * import { parseUnits, type ShieldSwapActions } from '@provablehq/shield-swap-sdk' + * + * async function prepareUsdcxInventory(client: WalletClient & ShieldSwapActions) { + * // Enable recordManagement on the client before executing maintenance. + * // This convenience action creates a fresh plan from the current inventory. + * // For a reviewed plan, call client.rebalanceRecordInventory({ plan }). + * // Extend a wallet client with shieldSwapActions({ api: {} }) first. + * const token = await client.tokenData('USDCx') + * const result = await client.rebalanceInventory({ + * token: token.id, + * target: { + * records: 6, + * minRecordAmount: parseUnits('0.1', token.decimals), + * distribution: 'balanced', + * toleranceBps: 0, + * }, + * maxTransactions: 24, + * timeoutMs: 120_000, + * pollIntervalMs: 2_000, + * maxFeeMicrocredits: 10_000_000n, + * }) + * console.log('Transactions:', result.transactionIds) + * if (result.status === 'interrupted') { + * // Stop dependent swaps. Reconcile the shared journal before replanning; + * // previously accepted joins or splits remain effective. + * throw new Error(result.reason ?? 'Inventory maintenance interrupted') + * } + * return result + * } + */ +export async function rebalanceInventory(client: Client, params: RebalanceInventoryParameters): Promise { + // Plan joins and splits for the DEX token's underlying asset. Core reserves + // the inputs and waits for confirmed outputs before executing dependent steps. + const plan = await planInventory(client, params) + return rebalanceRecordInventory(client, { ...params, plan }) +} diff --git a/packages/shield-swap/src/actions/swap/internal.ts b/packages/shield-swap/src/actions/swap/internal.ts index 5f4b9249..7e705a35 100644 --- a/packages/shield-swap/src/actions/swap/internal.ts +++ b/packages/shield-swap/src/actions/swap/internal.ts @@ -1,4 +1,5 @@ -import type { Client } from '@provablehq/veil-core' +import { RecordReservationConflictError, inventoryRecordId, type Client } from '@provablehq/veil-core' +import { resolveTokenRecord, tokenRecordCandidates } from '../../utils/records.js' import { getSlot } from '../reads/getSlot.js' import { getTradeControls } from '../reads/getTradeControls.js' import { resolveDexImports } from '../../utils/imports.js' @@ -109,3 +110,56 @@ export async function prepareQuote(client: Client, q: SwapQuote, imports?: Recor })) return resolveDexImports(client, { tokenPrograms: [...programs], program: q.program }) } + +/** Captures the signing account and reservation namespace before preparing a swap. @internal */ +export function swapContextGuard(client: Client): () => void { + const account = client.account + const address = account?.address + const network = client.transport?.config.network + const management = client.recordManagement + const enabled = management?.enabled + const store = management?.store + const chainId = management?.chainId + return () => { + // A replacement record must never carry an old identity or quote into a + // different account, network, or coordination store. + if (client.account !== account || client.account?.address !== address || client.transport?.config.network !== network || + client.recordManagement !== management || management?.enabled !== enabled || management?.store !== store || management?.chainId !== chainId) { + throw new Error('Account, chain, or record management changed during swap preparation') + } + } +} + +/** Retries only unacquired automatic inputs; keeps the original swap terms and identity in the callback. @internal */ +export async function executeWithTokenRecord( + client: Client, + params: Parameters[1], + execute: (record: string) => Promise, +): Promise { + // Explicit inputs, wallet requests, and disabled management retain their + // existing single-attempt behavior. Only local automatic selection retries. + if (params.tokenRecord !== undefined || client.recordManagement?.enabled !== true || !params.tokenInProgram) { + return execute(await resolveTokenRecord(client, params)) + } + const candidates = await tokenRecordCandidates(client, { + program: params.tokenInProgram, minAmount: params.minAmount, tokenId: params.tokenId, + }) + const attempted = new Set() + let conflict: RecordReservationConflictError | undefined + for (const candidate of candidates) { + const id = inventoryRecordId(candidate.record) + if (attempted.has(id)) continue + attempted.add(id) + try { + // The callback rechecks scope and quote expiry, then rebuilds only the + // record-dependent input list. Core remains the atomic reservation owner. + return await execute(candidate.record.recordPlaintext) + } catch (error) { + if (!(error instanceof RecordReservationConflictError) || !error.recordIds.includes(id)) throw error + conflict = error + } + } + // One fixed scan bounds the work even when another process repeatedly wins + // reservations or the scanner continues returning spent inputs. + throw conflict! +} diff --git a/packages/shield-swap/src/actions/swap/swap.ts b/packages/shield-swap/src/actions/swap/swap.ts index 78fe7d40..c696c64c 100644 --- a/packages/shield-swap/src/actions/swap/swap.ts +++ b/packages/shield-swap/src/actions/swap/swap.ts @@ -1,5 +1,5 @@ import type { SwapQuote } from './quote.js' -import { assertQuote, prepareQuote, executionQuote, assertExecutionQuote } from './internal.js' +import { assertQuote, prepareQuote, executionQuote, assertExecutionQuote, swapContextGuard, executeWithTokenRecord } from './internal.js' import { swapMultiHop, type MultiHopSwapHandle } from './swapMultiHop.js' import { executeContract, @@ -12,7 +12,6 @@ import { reserveBlindedIdentity } from '../blinding/reserveBlindedIdentity.js' import { recordSwapOrThrow } from '../../utils/blinding/tracking.js' import type { BlindedIdentityStore } from '../../utils/blinding/store.js' import { nextBlindedIdentity, viewKeyToScalar } from '../../utils/blinding/identity.js' -import { resolveTokenRecord } from '../../utils/records.js' import { requireAccount, requirePool, requireSlot } from '../../utils/guards.js' import { resolveSwapParams, getDeadline, generateSwapNonce } from '../../utils/params.js' import { blindingFactorIssueRequest, blindedAddressIssueRequest } from '../../utils/blinding/requests.js' @@ -217,6 +216,10 @@ export type SwapReturnType = SwapHandle * scan, and the transaction itself. Signs, and on the local path proves * locally. * + * Enabled record management retries automatic local record selection when + * another operation reserves the selected input before proving. Each candidate + * is tried once; explicit tokenRecord inputs and execution failures are not retried. + * * @param client A Veil wallet client (local or wallet account). * @param params The swap intent and optional overrides. * @returns The swap handle — persist it; the claim consumes it. @@ -265,6 +268,10 @@ export async function swap(client: Client, params: SwapParameters | SwapFromQuot ? swap(client, { ...execution, poolKey: q.hops[0]!.poolKey }) : swapMultiHop(client, { ...execution, poolKeys: q.hops.map((hop) => hop.poolKey) }) } + // Snapshot caller-owned terms before asynchronous preparation or retries. + params = { ...params, imports: params.imports && { ...params.imports }, + blindedIdentity: params.blindedIdentity && { ...params.blindedIdentity } } + const assertContext = swapContextGuard(client) const program = params.program ?? SHIELD_SWAP const routerProgram = params.routerProgram ?? SHIELD_SWAP_ROUTER @@ -355,28 +362,29 @@ export async function swap(client: Client, params: SwapParameters | SwapFromQuot // Wrapped inputs spend the UNDERLYING asset's records; plain inputs // spend the token program's own records (the id decodes to the program). - const recordInput = await resolveTokenRecord(client, { + const result = await executeWithTokenRecord(client, { tokenRecord: params.tokenRecord, tokenInProgram: route.wrapped ? route.underlyingProgram : tokenIdToProgram(route.tokenId), tokenId: route.wrapped ? route.underlyingId : params.tokenInId, minAmount: params.amountIn, + }, recordInput => { + assertContext() + assertExecutionQuote(client, params) + return route.wrapped + ? executeContract(client, { + program: routerProgram, + function: 'swap_from_wrapped', + imports: params.imports, + inputs: [recordInput, senderProof!, identity.blindingFactor, identity.blindedAddress, ...tail], + }) + : executeContract(client, { + program, + function: 'swap', + imports: params.imports, + inputs: [recordInput, identity.blindingFactor, identity.blindedAddress, ...tail], + }) }) - assertExecutionQuote(client, params) - const result = route.wrapped - ? await executeContract(client, { - program: routerProgram, - function: 'swap_from_wrapped', - imports: params.imports, - inputs: [recordInput, senderProof!, identity.blindingFactor, identity.blindedAddress, ...tail], - }) - : await executeContract(client, { - program, - function: 'swap', - imports: params.imports, - inputs: [recordInput, identity.blindingFactor, identity.blindedAddress, ...tail], - }) - const swapId = requireFieldOutput(result.outputs, 'swap') const handle = { @@ -422,6 +430,7 @@ export async function swap(client: Client, params: SwapParameters | SwapFromQuot }).catch(() => undefined) } + assertContext() assertExecutionQuote(client, params) const transactionId = await writeContract(client, { program: route.wrapped ? routerProgram : program, diff --git a/packages/shield-swap/src/actions/swap/swapMultiHop.ts b/packages/shield-swap/src/actions/swap/swapMultiHop.ts index a598664d..4c208176 100644 --- a/packages/shield-swap/src/actions/swap/swapMultiHop.ts +++ b/packages/shield-swap/src/actions/swap/swapMultiHop.ts @@ -1,4 +1,4 @@ -import { assertExecutionQuote } from './internal.js' +import { assertExecutionQuote, swapContextGuard, executeWithTokenRecord } from './internal.js' import { executeContract, writeContract, @@ -10,7 +10,6 @@ import { reserveBlindedIdentity } from '../blinding/reserveBlindedIdentity.js' import { recordSwapOrThrow } from '../../utils/blinding/tracking.js' import type { BlindedIdentityStore } from '../../utils/blinding/store.js' import { nextBlindedIdentity, viewKeyToScalar } from '../../utils/blinding/identity.js' -import { resolveTokenRecord } from '../../utils/records.js' import { requireAccount, requirePool, requireSlot } from '../../utils/guards.js' import { resolveMultiHopParams, @@ -178,6 +177,10 @@ export type SwapMultiHopReturnType = MultiHopSwapHandle * deadline read, a record scan (local), and the transaction itself. Signs, * and on the local path proves locally. * + * Enabled record management retries automatic local record selection when + * another operation reserves the selected input before proving. Each candidate + * is tried once; explicit tokenRecord inputs and execution failures are not retried. + * * @param client A Veil wallet client (local or wallet account). * @param params The route intent and optional overrides. * @returns The multi-hop handle — persist it; the claim consumes it. @@ -198,6 +201,10 @@ export type SwapMultiHopReturnType = MultiHopSwapHandle * // const res = await claimSwapOutput(client, { handle }) */ export async function swapMultiHop(client: Client, params: SwapMultiHopParameters): Promise { + // Snapshot caller-owned terms before asynchronous preparation or retries. + params = { ...params, imports: params.imports && { ...params.imports }, + blindedIdentity: params.blindedIdentity && { ...params.blindedIdentity }, poolKeys: [...params.poolKeys] } + const assertContext = swapContextGuard(client) const program = params.program ?? SHIELD_SWAP const routerProgram = params.routerProgram ?? SHIELD_SWAP_ROUTER @@ -288,28 +295,29 @@ export async function swapMultiHop(client: Client, params: SwapMultiHopParameter // Wrapped inputs spend the UNDERLYING asset's records; plain inputs // spend the token program's own records (the id decodes to the program). - const recordInput = await resolveTokenRecord(client, { + const result = await executeWithTokenRecord(client, { tokenRecord: params.tokenRecord, tokenInProgram: route.wrapped ? route.underlyingProgram : tokenIdToProgram(route.tokenId), tokenId: route.wrapped ? route.underlyingId : params.tokenInId, minAmount: params.amountIn, + }, recordInput => { + assertContext() + assertExecutionQuote(client, params) + return route.wrapped + ? executeContract(client, { + program: routerProgram, + function: 'swap_mh_from_wrapped', + imports: params.imports, + inputs: [recordInput, senderProof!, identity.blindingFactor, identity.blindedAddress, ...tail], + }) + : executeContract(client, { + program, + function: 'swap_multi_hop', + imports: params.imports, + inputs: [identity.blindingFactor, identity.blindedAddress, recordInput, ...tail], + }) }) - assertExecutionQuote(client, params) - const result = route.wrapped - ? await executeContract(client, { - program: routerProgram, - function: 'swap_mh_from_wrapped', - imports: params.imports, - inputs: [recordInput, senderProof!, identity.blindingFactor, identity.blindedAddress, ...tail], - }) - : await executeContract(client, { - program, - function: 'swap_multi_hop', - imports: params.imports, - inputs: [identity.blindingFactor, identity.blindedAddress, recordInput, ...tail], - }) - const swapId = requireFieldOutput(result.outputs, 'swap_multi_hop') const handle = { ...handleBase, @@ -353,6 +361,7 @@ export async function swapMultiHop(client: Client, params: SwapMultiHopParameter }).catch(() => undefined) } + assertContext() assertExecutionQuote(client, params) const transactionId = await writeContract(client, { program: route.wrapped ? routerProgram : program, diff --git a/packages/shield-swap/src/decorators/shieldSwapActions.ts b/packages/shield-swap/src/decorators/shieldSwapActions.ts index 77edb63b..d385aa7b 100644 --- a/packages/shield-swap/src/decorators/shieldSwapActions.ts +++ b/packages/shield-swap/src/decorators/shieldSwapActions.ts @@ -1,3 +1,6 @@ +import { planInventory, type PlanInventoryParameters } from '../actions/inventory/planInventory.js' +import { rebalanceInventory, type RebalanceInventoryParameters } from '../actions/inventory/rebalanceInventory.js' +import type { InventoryPlan, InventoryResult } from '@provablehq/veil-core' import { waitForSwapOutput, type WaitForSwapOutputParameters, type WaitForSwapOutputReturnType } from '../actions/reads/waitForSwapOutput.js' import type { Client } from '@provablehq/veil-core' import type { ComplianceProofs, FreezelistConfig, ProofInputProvider } from '../utils/proofs.js' @@ -154,7 +157,10 @@ export type ShieldSwapActionsConfig = { } /** - * The action surface {@link shieldSwapActions} adds to a client. + * Defines the action surface {@link shieldSwapActions} adds to a client. + * + * @property planInventory Plans the underlying token's inventory without submitting. + * @property rebalanceInventory Rebalances underlying records using shared core coordination. * * @property resolveDexImports Builds the `imports` map a write needs — the * given token programs plus the DEX program's own declared imports. Every @@ -209,6 +215,8 @@ export type ShieldSwapActionsConfig = { * `api` was configured. */ export type ShieldSwapActions = { + planInventory: (params: Omit) => Promise + rebalanceInventory: (params: Omit) => Promise waitForSwapOutput: (params: WaitForSwapOutputParameters) => Promise getPool: (params: GetPoolParameters) => Promise getSlot: (params: GetSlotParameters) => Promise @@ -377,6 +385,8 @@ export function shieldSwapActions(config: ShieldSwapActionsConfig = {}) { } } return { + planInventory: (p) => planInventory(client, { ...p, api: api ?? missingApi }), + rebalanceInventory: (p) => rebalanceInventory(client, { ...p, api: api ?? missingApi }), getPool: (p) => getPool(client, withProgram(p)), getSlot: (p) => getSlot(client, withProgram(p)), waitForSwapOutput: (p) => waitForSwapOutput(client, p), diff --git a/packages/shield-swap/src/index.ts b/packages/shield-swap/src/index.ts index 8c387762..0f96262c 100644 --- a/packages/shield-swap/src/index.ts +++ b/packages/shield-swap/src/index.ts @@ -361,3 +361,6 @@ export { } from './utils/detectTokenStandard.js' export { shieldSwapFreezelistGrants, type ShieldSwapFreezelistGrantsParameters } from './utils/freezelistGrants.js' + +export { planInventory, type PlanInventoryParameters } from './actions/inventory/planInventory.js' +export { rebalanceInventory, type RebalanceInventoryParameters } from './actions/inventory/rebalanceInventory.js' diff --git a/packages/shield-swap/src/utils/records.ts b/packages/shield-swap/src/utils/records.ts index eea601c3..dbe21f04 100644 --- a/packages/shield-swap/src/utils/records.ts +++ b/packages/shield-swap/src/utils/records.ts @@ -1,4 +1,4 @@ -import { requestRecords, parseRecord, type Client, type InputRequest, type OwnedRecord } from '@provablehq/veil-core' +import { requestRecords, parseRecord, reservedRecordIds, inventoryRecordId, type Client, type InputRequest, type OwnedRecord } from '@provablehq/veil-core' import { toPositionNFT } from '../generated/shield_swap.js' /** @@ -99,14 +99,23 @@ export type SelectTokenRecordParameters = { * const { record } = await selectTokenRecord(client, { program: 'ethx_5a095e.aleo', minAmount: 10n ** 18n }) */ export async function selectTokenRecord(client: Client, params: SelectTokenRecordParameters): Promise { + return (await tokenRecordCandidates(client, params))[0]! +} + +/** Scans eligible records once, ordered smallest first, for bounded swap selection retries. @internal */ +export async function tokenRecordCandidates(client: Client, params: SelectTokenRecordParameters): Promise { const records = (await requestRecords(client, { program: params.program, statusFilter: 'unspent', })) as OwnedRecord[] - let best: TokenRecordInfo | undefined + // Exclude inputs held by participating inventory or trading operations even + // if the scanner still reports those records as unspent. + const reserved = await reservedRecordIds(client) + const candidates: TokenRecordInfo[] = [] for (const record of records) { if (!record.recordPlaintext) continue + if (reserved.size && reserved.has(inventoryRecordId(record))) continue const info = parseTokenRecordInfo(record.recordPlaintext) if (!info) continue // Recipient-bound wrapper records are single-purpose (unwrap to the bound @@ -114,17 +123,17 @@ export async function selectTokenRecord(client: Client, params: SelectTokenRecor if (info.recipientBound) continue if (params.tokenId !== undefined && info.tokenId !== undefined && info.tokenId !== params.tokenId) continue if (info.amount < params.minAmount) continue - if (!best || info.amount < best.amount) best = { ...info, record } + candidates.push({ ...info, record }) } - if (!best) { + if (!candidates.length) { throw new Error( `No unspent ${params.program} record covers ${params.minAmount}` + (params.tokenId ? ` for token ${params.tokenId}` : '') + ' — balance too low, or funds are public rather than in records.', ) } - return best + return candidates.sort((a, b) => a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : 0) } /** diff --git a/packages/shield-swap/test/actions/inventory.test.ts b/packages/shield-swap/test/actions/inventory.test.ts new file mode 100644 index 00000000..1fa810a6 --- /dev/null +++ b/packages/shield-swap/test/actions/inventory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createClient, memoryRecordInventoryStore, recordActions, type Client } from '@provablehq/veil-core' +import { planInventory } from '../../src/actions/inventory/planInventory.js' +import { selectTokenRecord } from '../../src/utils/records.js' +import type { ApiClient } from '../../src/api/client.js' + +const account = { type: 'rpc', address: 'aleo1owner' } +const scope = JSON.stringify(['testnet', account.address]) +const record = (program: string, amount: bigint, nonce: number) => ({ programName: program, recordName: program === 'credits.aleo' ? 'credits' : 'Token', spent: false, tag: `${nonce}field`, + recordPlaintext: `{ owner: aleo1owner.private, ${program === 'credits.aleo' ? 'microcredits' : 'amount'}: ${amount}${program === 'credits.aleo' ? 'u64' : 'u128'}.private, _nonce: ${nonce}group.public }` }) + +describe('Shield Swap inventory', () => { + it.each(['credits.aleo', 'test_usdcx_stablecoin.aleo'])('plans the underlying %s program, not its AMM wrapper', async (program) => { + const api = { getTokens: async () => ({ data: [{ address: '1field', symbol: 'ASSET', decimals: 6, + amm_token_program: 'wrapper.aleo', underlying_program: program }] }) } as unknown as ApiClient + const request = vi.fn(async ({ method, params }) => { + if (method === 'getProgram') return readFileSync(new URL(`../../../core/test/fixtures/programs/${program}`, import.meta.url), 'utf8') + if (method === 'requestRecords') { expect(params.program).toBe(program); return [record(program, 1_030_000n, 1)] } + throw new Error('Planning must not submit') + }) + const client = { account, transport: { config: { network: 'testnet' } }, request } as unknown as Client + const plan = await planInventory(client, { api, token: 'ASSET', target: { records: 4, distribution: 'balanced' } }) + expect(plan.asset).toEqual({ program, standard: program === 'credits.aleo' ? 'credits' : 'arc22', + recordName: program === 'credits.aleo' ? 'credits' : 'Token', amountField: program === 'credits.aleo' ? 'microcredits' : 'amount', + amountType: program === 'credits.aleo' ? 'u64' : 'u128' }) + expect(plan.steps).toHaveLength(3) + }) + it('keeps reserved inventory out of trading record selection', async () => { + const program = 'test_arc20_eth.aleo' + const store = memoryRecordInventoryStore() + await store.acquire({ scope, program, function: 'join', records: ['1group'], status: 'reserved', accountType: 'local', createdAt: Date.now() }) + const base = { account, transport: { config: { network: 'testnet' } }, request: async () => [record(program, 500n, 1), record(program, 700n, 2)] } as unknown as Client + const client = createClient({ account: base.account, transport: { ...base.transport, request: base.request }, + recordManagement: { enabled: true, store } }) + expect((await selectTokenRecord(client, { program, minAmount: 400n })).amount).toBe(700n) + }) + it('uses the configured router for the resolved underlying token inventory', async () => { + const program = 'test_arc20_eth.aleo' + const api = { getTokens: async () => ({ data: [{ address: '1field', symbol: 'ASSET', decimals: 6, + amm_token_program: 'wrapper.aleo', underlying_program: program }] }) } as unknown as ApiClient + const base = { account, transport: { config: { network: 'testnet' } }, request: async ({ method }: any) => { + if (method === 'getProgram') return readFileSync(new URL(`../../../core/test/fixtures/programs/${program}`, import.meta.url), 'utf8') + if (method === 'requestRecords') return Array.from({ length: 16 }, (_, i) => record(program, 100n, i + 1)) + throw new Error('Planning must not submit') + } } as unknown as Client + const tokenJoin = { program: 'test_aj_arc20_2_15.aleo' } + const client = Object.assign(base, recordActions({ tokenJoin })(base)) + const plan = await planInventory(client, { api, token: 'ASSET', target: { records: 1 }, maxTransactions: 2 }) + expect(plan.asset.program).toBe(program) + expect(plan.tokenJoin).toEqual(tokenJoin) + expect(plan.steps.map((step) => step.inputs.length)).toEqual([15, 2]) + }) +}) diff --git a/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts b/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts new file mode 100644 index 00000000..766eea48 --- /dev/null +++ b/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createWalletClient, memoryRecordInventoryStore, parseRecord, + type ExecuteOptions, type RecordInventoryStore, type OwnedRecord } from '@provablehq/veil-core' +import { swap } from '../../../src/actions/swap/swap.js' +import { swapMultiHop } from '../../../src/actions/swap/swapMultiHop.js' +import { executionQuote } from '../../../src/actions/swap/internal.js' +import { clearRouteCache, programToTokenId } from '../../../src/utils/routing.js' +import { EMPTY_MERKLE_PROOFS } from '../../../src/utils/proofs.js' + +const owner = 'aleo1owner' +const scope = JSON.stringify(['testnet', owner]) +const tokenA = programToTokenId('token_a') +const tokenB = programToTokenId('token_b') +const tokenC = programToTokenId('token_c') +const wrappedA = programToTokenId('wrapped_a') +const plaintext = (nonce: number, amount = 100n) => `{ owner: ${owner}.private, amount: ${amount}u128.private, _nonce: ${nonce}group.public }` +const records = (count: number): OwnedRecord[] => Array.from({ length: count }, (_, index) => ({ + programName: 'token_a.aleo', recordName: 'Token', spent: false, tag: `${index + 1}field`, recordPlaintext: plaintext(index + 1), +})) + +/** Forces every first attempt to reach acquisition before any writer can win. */ +function barrier(count: number) { + let remaining = count + let release!: () => void + const ready = new Promise(resolve => { release = resolve }) + return async () => { if (--remaining === 0) release(); await ready } +} + +/** Keeps selection, input construction, acquisition and lifecycle handling real; replaces only chain/scanner/prover I/O. */ +function world(options: { count?: number; racers?: number; wrapped?: boolean; enabled?: boolean } = {}) { + const backing = memoryRecordInventoryStore() + const firstAttempts = barrier(options.racers ?? 1) + const attempts: string[][] = [] + let onAcquire: (() => Promise) | undefined + let onProve: ((options: ExecuteOptions) => Promise) | undefined + const store: RecordInventoryStore = { + ...backing, + async acquire(entry) { + attempts.push([...entry.records]) + if (attempts.length <= (options.racers ?? 1)) await firstAttempts() + await onAcquire?.() + return backing.acquire(entry) + }, + } + const proved: ExecuteOptions[] = [] + const request = async ({ method, params }: any) => { + if (method === 'getLatestHeight' || method === 'getBlockNumber') return 1000n + if (method === 'getMappingValue') { + if (params.mapping === 'pools') { + const a = params.key === '1001field' ? (options.wrapped ? wrappedA : tokenA) : tokenB + const b = params.key === '1001field' ? tokenB : tokenC + return `{ token0: ${a}, token1: ${b}, fee: 3000u16, enabled: true }` + } + if (params.mapping === 'slots') return '{ tick: 0i32, tick_spacing: 60u32, sqrt_price: { hi: 1u128, lo: 0u128 }, fee_protocol: 0u8, liquidity: 10000000u128, fee_growth_global0_x_128: { hi: 0u128, lo: 0u128 }, fee_growth_global1_x_128: { hi: 0u128, lo: 0u128 }, max_liquidity_per_tick: 10000000u128, protocol_fees0: 0u128, protocol_fees1: 0u128, next_init_below: -60i32, next_init_above: 60i32 }' + if (params.mapping === 'from_wrapper_token_id') return options.wrapped && params.key === wrappedA ? tokenA : null + return null + } + throw new Error(`Unexpected chain method ${method}`) + } + const scan = vi.fn(async () => records(options.count ?? 6)) + const client = createWalletClient({ + account: { type: 'local', source: 'test', address: owner, privateKey: 'test', viewKey: 'test', sign: vi.fn(), signMessage: vi.fn() }, + transport: { config: { key: 'test', name: 'test', type: 'custom', network: 'testnet', request }, request }, + recordManagement: { enabled: options.enabled ?? true, store }, + recordProvider: { requestRecords: scan, setAccount() {} }, + proving: { mode: 'local', execute: async (options: ExecuteOptions) => { + proved.push(options) + const sequence = proved.length + await onProve?.(options) + const transactionId = `at1${sequence}` + await options.onProgress?.({ type: 'transaction-confirmed', transactionId }) + return { transactionId, transitions: [], outputs: [`${sequence}field`] } + } }, + }) + const params = (index = 1) => ({ + poolKey: '1001field', poolKeys: ['1001field', '1002field'], tokenInId: options.wrapped ? wrappedA : tokenA, + amountIn: 10n, expectedOut: 10n, nonce: BigInt(index), imports: { 'token_a.aleo': 'program token_a.aleo;' }, + blindedIdentity: { blindingFactor: `${index}field`, blindedAddress: `aleo1identity${index}` }, + proofs: async () => EMPTY_MERKLE_PROOFS, + }) + return { client, store, backing, attempts, proved, scan, params, + onAcquire: (fn: () => Promise) => { onAcquire = fn }, + onProve: (fn: (options: ExecuteOptions) => Promise) => { onProve = fn } } +} +const inputNonce = (options: ExecuteOptions) => parseRecord(options.inputs.find(input => input.includes('_nonce'))!).nonce +const hold = (store: RecordInventoryStore, id: string) => store.acquire({ scope, records: [id], program: 'token_a.aleo', function: 'join', status: 'reserved', accountType: 'local', createdAt: 1 }) +beforeEach(() => clearRouteCache()) + +for (const [name, action] of [['swap', swap], ['swapMultiHop', swapMultiHop]] as const) { + describe(`${name} concurrent input selection`, () => { + it.each([false, true])('reselects six distinct inputs when all first acquisitions race (wrapped=%s)', async wrapped => { + const w = world({ racers: 6, wrapped }) + const results = await Promise.allSettled(Array.from({ length: 6 }, (_, index) => action(w.client, w.params(index + 1)))) + expect(results.map(result => result.status)).toEqual(Array(6).fill('fulfilled')) + expect(w.attempts.slice(0, 6)).toEqual(Array.from({ length: 6 }, () => ['1group'])) + expect(w.proved.map(inputNonce).sort()).toEqual(['1group', '2group', '3group', '4group', '5group', '6group']) + expect(w.scan).toHaveBeenCalledTimes(6) + expect((await w.store.list(scope)).map(entry => entry.status)).toEqual(Array(6).fill('confirmed')) + expect(w.proved.every(call => call.functionName === (wrapped + ? name === 'swap' ? 'swap_from_wrapped' : 'swap_mh_from_wrapped' + : name === 'swap' ? 'swap' : 'swap_multi_hop'))).toBe(true) + for (const call of w.proved) { + const nonceIndex = call.inputs.length - (name === 'swap' ? 4 : 2) + const identityIndex = wrapped ? 2 : name === 'swap' ? 1 : 0 + expect(call.inputs[identityIndex]).toBe(call.inputs[nonceIndex]!.replace('u64', 'field')) + expect(call.inputs).toContain('10u128') + expect(call.programImports).toEqual({ 'token_a.aleo': 'program token_a.aleo;' }) + } + }) + + it('stops after its finite candidates are reserved, even when the scanner stays stale', async () => { + const w = world({ count: 2 }) + w.onAcquire(async () => { await hold(w.backing, '1group'); await hold(w.backing, '2group') }) + await expect(action(w.client, w.params())).rejects.toMatchObject({ name: 'RecordReservationConflictError' }) + expect(w.attempts).toEqual([['1group'], ['2group']]) + expect(w.proved).toHaveLength(0) + expect(w.scan).toHaveBeenCalledOnce() + }) + + it('keeps explicitly supplied records fixed on contention', async () => { + const w = world() + await hold(w.backing, '1group') + await expect(action(w.client, { ...w.params(), tokenRecord: plaintext(1) })).rejects.toThrow('reserved or spent') + expect(w.scan).not.toHaveBeenCalled() + expect(w.attempts).toEqual([['1group']]) + expect(w.proved).toHaveLength(0) + }) + + it.each(['before proving', 'prepared', 'submitted', 'confirmed'])('does not retry errors %s', async stage => { + const w = world() + w.onProve(async options => { + if (stage !== 'before proving') await options.onProgress?.({ type: 'transaction-prepared', transactionId: 'at1uncertain', transaction: { id: 'at1uncertain', type: 'execute' } } as any) + if (stage === 'submitted' || stage === 'confirmed') await options.onProgress?.({ type: 'transaction-submitted', transactionId: 'at1uncertain' }) + if (stage === 'confirmed') await options.onProgress?.({ type: 'transaction-confirmed', transactionId: 'at1uncertain' }) + throw new Error('Execution failed') + }) + await expect(action(w.client, w.params())).rejects.toThrow('Execution failed') + expect(w.proved.map(inputNonce)).toEqual(['1group']) + expect(w.attempts).toHaveLength(1) + expect((await w.store.list(scope))[0]?.status).toBe(stage === 'before proving' ? 'cancelled' : stage) + }) + + it.each(['account', 'network'])('refuses to retry after the %s changes', async change => { + const w = world() + w.onAcquire(async () => { + await hold(w.backing, '1group') + if (change === 'account') (w.client.account as any).address = 'aleo1other' + else (w.client.transport.config as any).network = 'mainnet' + }) + await expect(action(w.client, w.params())).rejects.toThrow(/changed/) + expect(w.proved).toHaveLength(0) + expect(w.attempts).toHaveLength(1) + }) + + it('preserves accepted terms and identity when caller objects change during contention', async () => { + const w = world() + const params = w.params(7) + w.onAcquire(async () => { + await hold(w.backing, '1group') + params.amountIn = 999n + params.expectedOut = 999n + params.blindedIdentity.blindingFactor = '999field' + params.imports['token_a.aleo'] = 'changed' + }) + const handle = await action(w.client, params) + expect(handle.amountIn).toBe(10n) + expect(handle.blindingFactor).toBe('7field') + expect(w.proved.map(inputNonce)).toEqual(['2group']) + expect(w.proved[0]!.inputs).not.toContain('999u128') + expect(w.proved[0]!.programImports).toEqual({ 'token_a.aleo': 'program token_a.aleo;' }) + }) + + it('does not retry a nested reservation conflict once its own inputs were acquired', async () => { + const w = world() + w.onProve(async options => { + // A caller callback can perform another managed action; its conflict + // says nothing about whether the outer spend reached the network. + await w.client.executeContract({ program: 'token_a.aleo', function: 'join', inputs: [options.inputs.find(input => input.includes('_nonce'))!] }) + }) + await expect(action(w.client, w.params())).rejects.toMatchObject({ + message: 'Record spending failed after acquiring inputs', cause: { name: 'RecordReservationConflictError' }, + }) + expect(w.proved).toHaveLength(1) + expect(w.attempts).toEqual([['1group'], ['1group']]) + }) + + it('retains legacy uncoordinated selection when management is disabled', async () => { + const w = world({ enabled: false }) + await Promise.all([action(w.client, w.params(1)), action(w.client, w.params(2))]) + expect(w.proved.map(inputNonce)).toEqual(['1group', '1group']) + expect(w.attempts).toHaveLength(0) + }) + }) +} + +it('checks quote expiration again after a conflicting acquisition without proving a replacement', async () => { + const w = world() + const now = Date.now() + const params = { ...w.params(), [executionQuote]: { + version: 1, network: 'testnet', program: 'shield_swap.aleo', quotedAt: now, expiresAt: now + 30_000, + amountIn: 10n, expectedOut: 10n, minOut: 9n, slippageBps: 1000, protocolRevision: 1, + from: { id: tokenA, symbol: 'A', decimals: 6 }, to: { id: tokenB, symbol: 'B', decimals: 6 }, + hops: [{ poolKey: '1001field', tokenInId: tokenA, tokenOutId: tokenB }], + } } + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + w.onAcquire(async () => { await hold(w.backing, '1group'); clock.mockReturnValue(now + 30_001) }) + try { + await expect(swap(w.client, params)).rejects.toThrow('Quote expired') + expect(w.attempts).toHaveLength(1) + expect(w.proved).toHaveLength(0) + } finally { clock.mockRestore() } +}) diff --git a/packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts b/packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts new file mode 100644 index 00000000..f014e3c5 --- /dev/null +++ b/packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { parseRecord, type InventoryRecord } from '@provablehq/veil-core' +import { formatUnits, parseUnits, type SwapHandle } from '../../src/index.js' +import { createInventoryHarness, poll, RUN_INVENTORY, WRITE_TIMEOUT, REBALANCE_TIMEOUT, + type InventoryHarness, type InventoryTestToken } from './recordInventoryHarness.js' + +/** + * Runs six concurrent USDCx swaps and six concurrent ETH return swaps with + * automatic record selection, real delegated proving, and a durable SQLite store. + * Every successful swap is claimed before batch failures are reported. Requires + * VEIL_INVENTORY_E2E=1 and the same existing testnet account as the inventory suites. + */ +describe.runIf(RUN_INVENTORY).sequential('live concurrent record inventory swaps', () => { + let h: InventoryHarness + let usdcx: InventoryTestToken + let eth: InventoryTestToken + let poolKey: string + let imports: Record + let tradeAmount: bigint + let failed = false + const transactionIds: string[] = [] + const consumed = new Set() + const step = (body: () => Promise) => async () => { failed = true; await body(); failed = false } + beforeEach(context => { if (failed) context.skip() }) + beforeAll(async () => { + h = await createInventoryHarness() + usdcx = h.tokens.find(token => token.symbol === 'USDCx')! + eth = h.tokens.find(token => token.symbol === 'ETH')! + tradeAmount = parseUnits(process.env.VEIL_INVENTORY_SWAP_USDCX ?? '0.1', usdcx.decimals) + if (tradeAmount <= 0n) throw new Error('VEIL_INVENTORY_SWAP_USDCX must be positive') + // API metadata identifies the pair; live chain state decides which pool is + // liquid and tradeable before any maintenance or swap is submitted. + const pools = (await h.client.api.getPools({ limit: 100 })).data.filter(pool => + [pool.token0, pool.token1].includes(usdcx.id) && [pool.token0, pool.token1].includes(eth.id)) + let deepest = 0n + for (const pool of pools) { + const slot = await h.client.getSlot({ poolKey: pool.key }) + const controls = await h.client.getTradeControls({ poolKey: pool.key }) + if (slot && slot.liquidity > deepest && controls.tradeable) { poolKey = pool.key; deepest = slot.liquidity } + } + if (!poolKey) throw new Error('No tradeable USDCx/ETH pool with on-chain liquidity') + imports = await h.client.resolveDexImports({ tokenPrograms: [usdcx.ammTokenProgram!, eth.ammTokenProgram!] }) + console.log(`concurrency: ${h.count} simultaneous swaps per batch; ${formatUnits(tradeAmount, usdcx.decimals)} USDCx per forward swap`) + }, WRITE_TIMEOUT) + afterAll(async () => { await h?.close() }) + + /** Forces first acquisitions to overlap while leaving SQLite acquisition and proving unchanged. */ + async function batch(from: InventoryTestToken, to: InventoryTestToken, amounts: bigint[]) { + const beforeIn = await h.scan(from) + const beforeOut = await h.scan(to) + const acquire = h.store.acquire + const prover = h.client.proving! + const execute = prover.execute! + const attempts: string[][] = [] + const proved: string[] = [] + let active = 0 + let peak = 0 + let release!: () => void + let timer: ReturnType | undefined + const gate = new Promise(resolve => { release = resolve }) + h.store.acquire = async entry => { + attempts.push([...entry.records]) + if (attempts.length <= amounts.length) { + // A failed preparation must not strand other callers behind the test + // barrier. The assertions below still require every first attempt. + timer ??= setTimeout(release, 60_000) + if (attempts.length === amounts.length) { clearTimeout(timer); release() } + await gate + } + return acquire(entry) + } + prover.execute = async options => { + // Record only nonces, never plaintext or key material, while measuring + // actual overlapping executions after their reservations have succeeded. + const input = options.inputs.find(value => value.includes('_nonce')) + if (!input) throw new Error('Swap reached proving without a record input') + proved.push(parseRecord(input).nonce) + peak = Math.max(peak, ++active) + try { return await execute(options) } + finally { active-- } + } + let results: PromiseSettledResult[] + try { + results = await Promise.allSettled(amounts.map(async amountIn => { + // Omit tokenRecord: selection and reselection must run through the SDK. + const handle = await h.client.swap({ poolKey, tokenInId: from.id, amountIn, slippageBps: 100, imports }) + transactionIds.push(handle.transactionId) + console.log(`${from.symbol} → ${to.symbol} swap confirmed: ${handle.transactionId}`) + return handle + })) + } finally { + if (timer) clearTimeout(timer) + release() + h.store.acquire = acquire + prover.execute = execute + } + const handles = results.filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled').map(result => result.value) + // Settle every claim before assertions, even if another swap or claim failed. + // Durable identity handles remain available for explicit recovery on failure. + const claims = await Promise.allSettled(handles.map(async handle => { + const finalized = await poll('concurrent swap output', () => h.client.getSwapOutput({ swapId: handle.swapId! }), output => output !== null) + const claim = await h.client.claimSwapOutput({ handle, imports }) + transactionIds.push(claim.transactionId) + const outputs = await h.outputRecords(to, claim.transactionId, 1) + await poll('claimed concurrent swap output', () => h.client.getSwapOutput({ swapId: handle.swapId! }), output => output === null) + expect(claim.amountOut).toBe(finalized!.amount_out) + expect(claim.amountOut).toBeGreaterThan(0n) + expect(claim.amountRemaining).toBe(0n) + expect(outputs[0]!.amount).toBe(claim.amountOut) + console.log(`${from.symbol} → ${to.symbol} claimed ${formatUnits(claim.amountOut, to.decimals)} ${to.symbol}: ${claim.transactionId}`) + return { handle, claim, record: outputs[0]! } + })) + const failures = [...results, ...claims].flatMap((result, index) => result.status === 'rejected' + ? [`operation ${index + 1}: ${result.reason instanceof Error ? result.reason.message : 'unknown failure'}`] : []) + expect(failures, 'all submitted swaps and successful outputs must settle').toEqual([]) + const settled = claims.map(result => (result as PromiseFulfilledResult<{ handle: SwapHandle; claim: { amountOut: bigint }; record: InventoryRecord }>).value) + expect(handles).toHaveLength(amounts.length) + expect(new Set(handles.map(handle => handle.transactionId)).size).toBe(amounts.length) + expect(new Set(handles.map(handle => handle.blindedAddress)).size).toBe(amounts.length) + expect(peak, 'proving/confirmation executions actually overlap').toBeGreaterThan(1) + expect(proved).toHaveLength(amounts.length) + expect(new Set(proved).size, 'every prover consumes a distinct input').toBe(amounts.length) + expect(attempts.slice(0, amounts.length)).toHaveLength(amounts.length) + if (from.symbol === 'USDCx') { + expect(new Set(attempts.slice(0, amounts.length).map(ids => ids[0])).size, 'all first selections collide').toBe(1) + expect(attempts.length, 'the losing selections actually retry').toBeGreaterThan(amounts.length) + } + for (const id of proved) { + expect(consumed.has(id), 'inputs from earlier batches are not reused').toBe(false) + consumed.add(id) + } + const afterIn = await h.scan(from) + const afterOut = await h.scan(to) + expect(afterIn.balance).toBe(beforeIn.balance - amounts.reduce((sum, amount) => sum + amount, 0n)) + expect(afterOut.balance).toBe(beforeOut.balance + settled.reduce((sum, result) => sum + result.claim.amountOut, 0n)) + expect(afterIn.available.some(record => proved.includes(record.id)), 'spent inputs stay excluded').toBe(false) + const journal = await h.store.list(h.scope) + for (const handle of handles) { + const entry = journal.find(entry => entry.transactionId === handle.transactionId) + expect(entry?.status).toBe('confirmed') + expect(entry?.records).toHaveLength(1) + expect(proved).toContain(entry!.records[0]) + expect((await h.identities.load()).find(identity => identity.swapId === handle.swapId)?.status).toBe('claimed') + } + console.log(`${from.symbol} batch verified: ${handles.length} swaps, ${claims.length} claims, ${peak} overlapping executions, ${attempts.length - handles.length} reservation conflicts`) + return { settled, proved } + } + + it('prepares balanced records above the minimum for both tokens', step(async () => { + for (const token of h.tokens) await h.rebalance(token) + const inventory = await h.assertBalanced(usdcx) + expect(inventory.available.every(record => record.amount - tradeAmount >= usdcx.minimum)).toBe(true) + }), REBALANCE_TIMEOUT * 2) + + it('swaps concurrently in both directions and restores the balanced inventories', step(async () => { + const beforeUSDCx = await h.assertBalanced(usdcx) + const beforeETH = await h.assertBalanced(eth) + const forward = await batch(usdcx, eth, Array(h.count).fill(tradeAmount)) + // Return the amounts received, letting automatic selection match each one + // to a covering record instead of passing those record plaintexts explicitly. + const backward = await batch(eth, usdcx, forward.settled.map(result => result.claim.amountOut)) + expect(new Set(backward.proved)).toEqual(new Set(forward.settled.map(result => result.record.id))) + for (const token of h.tokens) await h.rebalance(token) + expect((await h.assertBalanced(usdcx)).balance).toBe(beforeUSDCx.balance - tradeAmount * BigInt(h.count) + + backward.settled.reduce((sum, result) => sum + result.claim.amountOut, 0n)) + expect((await h.assertBalanced(eth)).balance).toBe(beforeETH.balance) + }), REBALANCE_TIMEOUT * 2 + WRITE_TIMEOUT * 4) + + it('retains every confirmed spend without duplicate inputs after reconnecting', step(async () => { + await h.restart() + const journal = await h.client.reconcileRecordInventory() + expect(transactionIds).toHaveLength(h.count * 4) + for (const id of transactionIds) { + const entry = journal.find(entry => entry.transactionId === id) + expect(entry?.status, `confirmed checkpoint ${id}`).toBe('confirmed') + expect(entry?.transaction).toBeUndefined() + } + const used = journal.filter(entry => entry.status === 'confirmed').flatMap(entry => entry.records) + expect(new Set(used).size).toBe(used.length) + for (const token of h.tokens) await h.assertBalanced(token) + }), WRITE_TIMEOUT) +}) diff --git a/packages/shield-swap/test/integration/recordInventory.e2e.test.ts b/packages/shield-swap/test/integration/recordInventory.e2e.test.ts new file mode 100644 index 00000000..58722ad2 --- /dev/null +++ b/packages/shield-swap/test/integration/recordInventory.e2e.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { recordActions } from '@provablehq/veil-core' +import { createInventoryHarness, plaintext, RUN_INVENTORY, WRITE_TIMEOUT, REBALANCE_TIMEOUT, type InventoryHarness } from './recordInventoryHarness.js' + +/** + * Exercises inventory methods through the real testnet scanner, prover, and chain. + * Explicitly spends funded testnet records; the normal suite skips this file. + * Run with VEIL_INVENTORY_E2E=1 VEIL_E2E_STATE_FILE= pnpm vitest run --bail=1. + * Retain .veil/inventory-e2e/ after failures: it holds prepared transactions and reservations. + */ +describe.runIf(RUN_INVENTORY).sequential('live record inventory lifecycle', () => { + let h: InventoryHarness + let failed = false + // A failed prerequisite stops later writes, even when the CLI omits --bail=1. + const step = (body: () => Promise) => async () => { failed = true; await body(); failed = false } + beforeEach(context => { if (failed) context.skip() }) + beforeAll(async () => { h = await createInventoryHarness() }, WRITE_TIMEOUT) + afterAll(async () => { await h?.close() }) + + it('reads defaults and overrides, rejects impossible targets, and keeps unmanaged execution disabled', step(async () => { + const before = (await h.store.list(h.scope)).length + for (const token of h.tokens) { + const inventory = await h.scan(token) + expect(inventory.asset.recordName).toBe('Token') + expect(inventory.asset.amountField).toBe('amount') + expect(inventory.asset.amountType).toBe('u128') + const explicit = await h.client.getRecordInventory({ asset: { ...token.asset, recordName: 'Token', amountField: 'amount' } }) + expect(explicit.balance).toBe(inventory.balance) + expect(explicit.available.map(record => record.id)).toEqual(inventory.available.map(record => record.id)) + const plan = await h.client.planInventory({ token: token.id, target: token.target, maxTransactions: 24 }) + expect(plan.asset.program).toBe(token.asset.program) + expect(plan.deduction).toBe(0n) + const unmanaged = h.unmanaged().extend(recordActions()) + expect(unmanaged.recordManagement).toBeUndefined() + await expect(unmanaged.rebalanceRecordInventory({ plan })).rejects.toThrow('recordManagement.enabled') + await expect(h.client.planRecordInventory({ asset: token.asset, + target: { records: h.count, minRecordAmount: inventory.balance + 1n } })).rejects.toThrow() + } + expect((await h.store.list(h.scope)).length, 'read/validation paths do not reserve or submit').toBe(before) + }), WRITE_TIMEOUT) + + it('excludes a shared reservation and rejects competing writes before proving', step(async () => { + const token = h.tokens[0]! + let inventory = await h.scan(token) + // A prior interrupted run may have consolidated this token already. Restore + // enough inputs for both the reservation check and the later helper chain. + if (inventory.available.length < 5) { + const prepared = await h.client.rebalanceInventory({ token: token.id, target: { records: 5, distribution: 'balanced' }, + maxTransactions: 6, timeoutMs: WRITE_TIMEOUT }) + expect(prepared.status, prepared.reason).toBe('complete') + inventory = await h.scan(token) + } + expect(inventory.available.length).toBeGreaterThanOrEqual(2) + const [record, other] = inventory.available + const held = await h.store.acquire({ scope: h.scope, records: [record!.id], program: token.asset.program, + function: 'join', status: 'reserved', createdAt: Date.now(), accountType: 'local' }) + expect(Boolean(held)).toBe(true) + try { + const peer = h.peer() + const visible = await peer.getRecordInventory({ asset: token.asset }) + expect(visible.available.some(item => item.id === record!.id)).toBe(false) + expect(visible.reserved.map(item => item.id)).toContain(record!.id) + expect(visible.balance).toBe(inventory.balance - record!.amount) + await expect(peer.joinRecords({ asset: token.asset, records: [record!, other!] })).rejects.toThrow('already reserved') + const call = { program: token.asset.program, function: 'split', inputs: [plaintext(record!), '1u128'] } + await expect(peer.writeContract(call)).rejects.toThrow('already reserved') + await expect(peer.executeContract(call)).rejects.toThrow('already reserved') + expect((await h.store.list(h.scope)).find(entry => entry.id === held!.id)?.transactionId).toBeUndefined() + } finally { + // This entry never reaches proving or broadcast; release only this synthetic reservation. + await h.store.update(h.scope, held!.id, { status: 'cancelled' }) + } + expect((await h.scan(token)).balance).toBe(inventory.balance) + }), REBALANCE_TIMEOUT) + + it.each(['USDCx', 'ETH'])('joins three %s records with a helper and consolidates the remaining inventory', symbol => step(async () => { + const token = h.tokens.find(token => token.symbol === symbol)! + let before = await h.scan(token) + // Leave at least three inputs after the first join so autoJoin must also + // resolve a helper output, including when rerunning a consolidated account. + if (before.available.length < 5) { + const prepared = await h.client.rebalanceInventory({ token: token.id, target: { records: 5, distribution: 'balanced' }, + maxTransactions: 6, timeoutMs: WRITE_TIMEOUT }) + expect(prepared.status, prepared.reason).toBe('complete') + before = await h.scan(token) + } + const inputs = before.available.slice(0, 3) + const transactionId = await h.client.joinRecords({ asset: token.asset, records: inputs }) + const outputs = await h.outputRecords(token, transactionId, 1) + expect(outputs[0]!.amount).toBe(inputs.reduce((sum, record) => sum + record.amount, 0n)) + const joined = await h.scan(token) + expect(joined.available.length).toBe(before.available.length - 2) + expect(joined.balance).toBe(before.balance) + const journal = (await h.store.list(h.scope)).find(entry => entry.transactionId === transactionId)! + expect(journal.program).toBe(symbol === 'USDCx' ? 'aj_test_usdcx_stablecoin_2_10.aleo' : 'test_aj_arc20_2_15.aleo') + expect(journal.function).toBe('join_3') + expect(journal.assetProgram).toBe(token.asset.program) + expect(journal.status).toBe('confirmed') + expect(journal.records.slice().sort()).toEqual(inputs.map(record => record.id).sort()) + const consolidated = await h.client.autoJoin({ asset: token.asset, maxTransactions: 16, + timeoutMs: WRITE_TIMEOUT, pollIntervalMs: 3000 }) + expect(consolidated.status, consolidated.reason).toBe('complete') + const after = await h.scan(token) + expect(after.available.length).toBe(1) + expect(after.balance).toBe(before.balance) + const unchanged = (await h.store.list(h.scope)).length + const sufficient = await h.client.autoJoin({ asset: token.asset, minAmount: token.minimum }) + expect(sufficient.transactionIds).toEqual([]) + expect((await h.store.list(h.scope)).length).toBe(unchanged) + })(), REBALANCE_TIMEOUT) + + it.each(['USDCx', 'ETH'])('executes a reviewed %s plan and preserves a satisfied Shield Swap target', symbol => step(async () => { + const token = h.tokens.find(token => token.symbol === symbol)! + const before = await h.scan(token) + const core = await h.client.planRecordInventory({ asset: token.asset, target: token.target, maxTransactions: 24 }) + const dex = await h.client.planInventory({ token: token.id, target: token.target, maxTransactions: 24 }) + expect(dex).toEqual(core) + expect(core.steps.length).toBe(h.count - 1) + const result = await h.client.rebalanceRecordInventory({ plan: core, timeoutMs: WRITE_TIMEOUT, pollIntervalMs: 3000 }) + expect(result.status, result.reason).toBe('complete') + expect(result.completedSteps).toBe(h.count - 1) + expect((await h.assertBalanced(token)).balance).toBe(before.balance) + const satisfied = await h.rebalance(token) + expect(satisfied.completedSteps).toBe(0) + })(), REBALANCE_TIMEOUT) + + it('recovers a prepared split after reopening the store, then rejoins its exact outputs', step(async () => { + const token = h.tokens[0]! + const before = await h.scan(token) + const input = before.available[0]! + let preparedId: string | undefined + const interrupted = new Error('test interruption after durable preparation') + await expect(h.client.splitRecord({ asset: token.asset, record: input, amount: token.minimum, + onProgress: event => { + if (event.type === 'transaction-prepared') { preparedId = event.transactionId; throw interrupted } + } })).rejects.toThrow(interrupted.message) + expect(typeof preparedId).toBe('string') + await h.restart() + const saved = (await h.store.list(h.scope)).find(entry => entry.transactionId === preparedId)! + expect(saved.status).toBe('prepared') + expect(saved.transaction?.id).toBe(preparedId) + expect((await h.scan(token)).reserved.some(record => record.id === input.id)).toBe(true) + // Reconciliation submits the already proved transaction with its original id. + // It must not construct a replacement that could race the first spend. + await h.client.reconcileRecordInventory({ rebroadcast: true }) + const outputs = await h.outputRecords(token, preparedId!, 2) + expect(outputs.map(record => record.amount).sort((a, b) => a < b ? -1 : 1)) + .toEqual([token.minimum, input.amount - token.minimum].sort((a, b) => a < b ? -1 : 1)) + const restored = await h.client.joinRecords({ asset: token.asset, records: outputs }) + expect((await h.outputRecords(token, restored, 1))[0]!.amount).toBe(input.amount) + expect((await h.assertBalanced(token)).balance).toBe(before.balance) + const reconciled = await h.client.reconcileRecordInventory() + const recovered = reconciled.find(entry => entry.transactionId === preparedId)! + expect(recovered.status).toBe('confirmed') + expect(recovered.transaction).toBeUndefined() + expect(reconciled.filter(entry => entry.transactionId === preparedId).length).toBe(1) + }), WRITE_TIMEOUT * 3) +}) diff --git a/packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts b/packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts new file mode 100644 index 00000000..471a2d0d --- /dev/null +++ b/packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts @@ -0,0 +1,175 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createInventoryHarness, plaintext, RUN_INVENTORY, WRITE_TIMEOUT, REBALANCE_TIMEOUT, + type InventoryHarness, type InventoryTestToken } from './recordInventoryHarness.js' + +/** + * Checks execution guards and native join fallback against the real testnet. + * Uses the hosted scanner, delegated prover, ledger, and durable SQLite journal. + * Run with VEIL_INVENTORY_E2E=1 VEIL_E2E_STATE_FILE= pnpm vitest run --bail=1. + * Retain .veil/inventory-e2e/ after interruption; never repeat an uncertain submission. + */ +describe.runIf(RUN_INVENTORY).sequential('live inventory execution guards', () => { + let h: InventoryHarness + let failed = false + const submitted: string[] = [] + const step = (body: () => Promise) => async () => { failed = true; await body(); failed = false } + beforeEach(context => { if (failed) context.skip() }) + beforeAll(async () => { h = await createInventoryHarness() }, WRITE_TIMEOUT) + afterAll(async () => { await h?.close() }) + + // Check the actual accepted transition and scanner outputs before using either + // child record. A separate deposit cannot satisfy these commitments. + async function split(token: InventoryTestToken, record: Parameters[0], amount: bigint) { + const id = await h.client.splitRecord({ asset: token.asset, record, amount }) + submitted.push(id) + const outputs = await h.outputRecords(token, id, 2) + expect(outputs.map(output => output.amount).sort((a, b) => a < b ? -1 : 1)) + .toEqual([amount, record.amount - amount].sort((a, b) => a < b ? -1 : 1)) + console.log(`${token.symbol} native split confirmed: ${id}`) + return outputs + } + + it('prepares balanced inventory for both tokens before testing write guards', step(async () => { + for (const token of h.tokens) await h.rebalance(token) + }), REBALANCE_TIMEOUT * 2) + + it('rejects modified plans, cancellation, and newly reserved inputs before submission', step(async () => { + const token = h.tokens[0]! + const before = await h.scan(token) + const plan = await h.client.planRecordInventory({ asset: token.asset, target: { records: 1 } }) + const count = (await h.store.list(h.scope)).length + expect(plan.steps.length).toBeGreaterThan(0) + const modified = structuredClone(plan) + modified.steps[0]!.outputs[0]!.amount++ + await expect(h.client.rebalanceRecordInventory({ plan: modified })).rejects.toThrow('modified') + const cancelled = await h.client.rebalanceRecordInventory({ plan, signal: AbortSignal.abort(new Error('Cancelled by caller')) }) + expect(cancelled).toMatchObject({ status: 'interrupted', completedSteps: 0, transactionIds: [], reason: 'Cancelled by caller' }) + expect(await h.store.list(h.scope)).toHaveLength(count) + const held = await h.store.acquire({ scope: h.scope, records: [plan.steps[0]!.inputs[0]!], + program: token.asset.program, function: 'join', status: 'reserved', createdAt: Date.now(), accountType: 'local' }) + expect(held).toBeDefined() + try { + const stale = await h.client.rebalanceRecordInventory({ plan }) + expect(stale).toMatchObject({ status: 'interrupted', completedSteps: 0, transactionIds: [] }) + expect(stale.reason).toContain('Inventory changed') + expect(await h.store.list(h.scope)).toHaveLength(count + 1) + } finally { + // This reservation never reaches proving. Release only this test's entry. + await h.store.update(h.scope, held!.id, { status: 'cancelled' }) + } + expect((await h.scan(token)).balance).toBe(before.balance) + }), WRITE_TIMEOUT) + + it.each(['writeContract', 'executeContract'] as const)('%s rejects an excessive proved fee before broadcast', action => step(async () => { + const token = h.tokens[0]! + const before = await h.scan(token) + const input = before.available[0]! + const count = (await h.store.list(h.scope)).length + const limited = h.peer({ maxFeeMicrocredits: 0n }) + // The real prover builds the split, but the zero ceiling must prevent its + // broadcast even when FeeMaster would have paid the network fee. + await expect(limited[action]({ program: token.asset.program, function: 'split', inputs: [plaintext(input), '1u128'] })) + .rejects.toThrow('Proved transaction exceeds maxFeeMicrocredits') + const entries = (await h.store.list(h.scope)).slice(count) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ status: 'cancelled', records: [input.id] }) + expect(entries[0]!.transactionId).toBeUndefined() + const after = await h.scan(token) + expect(after.available.some(record => record.id === input.id)).toBe(true) + expect(after.balance).toBe(before.balance) + })(), WRITE_TIMEOUT) + + it.each(['USDCx', 'ETH'])('uses two native %s joins when helpers are disabled and restores the original lots', symbol => step(async () => { + const token = h.tokens.find(token => token.symbol === symbol)! + const before = await h.assertBalanced(token) + const sorted = before.available.map(record => record.amount).sort((a, b) => a > b ? -1 : 1) + const threshold = sorted[0]! + sorted[1]! + sorted[2]! + const native = h.peer({ tokenJoin: false }) + const result = await native.autoJoin({ asset: token.asset, minAmount: threshold, + maxTransactions: 2, timeoutMs: WRITE_TIMEOUT, pollIntervalMs: 3_000 }) + submitted.push(...result.transactionIds) + expect(result.status, result.reason).toBe('complete') + expect(result.completedSteps).toBe(2) + expect(result.transactionIds).toHaveLength(2) + const journal = await h.store.list(h.scope) + const consumed = new Set() + for (const id of result.transactionIds) { + const entry = journal.find(entry => entry.transactionId === id)! + expect(entry).toMatchObject({ program: token.asset.program, function: 'join', status: 'confirmed' }) + expect(entry.records).toHaveLength(2) + for (const nonce of entry.records) consumed.add(nonce) + const tx = await h.confirm(id) + expect(tx.execution!.transitions.map(transition => `${transition.program}/${transition.function}`)) + .toEqual([`${token.asset.program}/join`]) + console.log(`${symbol} native join confirmed: ${id}`) + } + const originals = before.available.filter(record => consumed.has(record.id)) + expect(originals).toHaveLength(3) + const joined = await h.scan(token) + expect(joined.balance).toBe(before.balance) + expect(joined.available).toHaveLength(h.count - 2) + const unchanged = before.available.filter(record => !consumed.has(record.id)).map(record => record.id).sort() + expect(joined.available.filter(record => before.available.some(previous => previous.id === record.id)).map(record => record.id).sort()) + .toEqual(unchanged) + const combined = joined.available.find(record => record.amount === threshold)! + expect(combined).toBeDefined() + // Restore the three consumed lots with two native splits. Their total token + // value is unchanged, and the untouched records retain their original nonces. + const first = await split(token, combined, originals[0]!.amount) + const remainder = first.find(record => record.amount === threshold - originals[0]!.amount)! + await split(token, remainder, originals[1]!.amount) + const restored = await h.assertBalanced(token) + expect(restored.balance).toBe(before.balance) + expect(restored.available.map(record => record.amount).sort()).toEqual(before.available.map(record => record.amount).sort()) + })(), WRITE_TIMEOUT * 5) + + it('stops a dependent join sequence after submission and retains the first spend until confirmation', step(async () => { + const token = h.tokens[0]! + const before = await h.assertBalanced(token) + const native = h.peer({ tokenJoin: false }) + const amounts = before.available.map(record => record.amount).sort((a, b) => a > b ? -1 : 1) + const controller = new AbortController() + const update = h.store.update + // Cancel at the persisted submission boundary while leaving proving, sending, + // confirmation, and every journal write on their real implementations. + h.store.update = async (scope, id, patch) => { + await update(scope, id, patch) + if (patch.status === 'submitted') controller.abort(new Error('Cancel after submission')) + } + let result: Awaited> + try { + result = await native.autoJoin({ asset: token.asset, minAmount: amounts[0]! + amounts[1]! + amounts[2]!, + maxTransactions: 2, timeoutMs: WRITE_TIMEOUT, pollIntervalMs: 3_000, signal: controller.signal }) + } finally { h.store.update = update } + submitted.push(...result.transactionIds) + expect(result.status).toBe('interrupted') + expect(result.reason).toBe('Cancel after submission') + expect(result.transactionIds).toHaveLength(1) + const id = result.transactionIds[0]! + const entry = (await h.store.list(h.scope)).find(entry => entry.transactionId === id)! + expect(entry.status).toBe('submitted') + expect(entry.transaction?.id).toBe(id) + expect(entry.records).toHaveLength(2) + const originals = before.available.filter(record => entry.records.includes(record.id)) + expect(originals).toHaveLength(2) + // Cancellation stops the next step; it does not undo a submitted join. + const [output] = await h.outputRecords(token, id, 1) + expect(output!.amount).toBe(originals[0]!.amount + originals[1]!.amount) + await split(token, output!, originals[0]!.amount) + expect((await h.assertBalanced(token)).balance).toBe(before.balance) + console.log(`Cancellation preserved the submitted join and stopped the second: ${id}`) + }), WRITE_TIMEOUT * 3) + + it('preserves confirmed spends and restored balances after reopening the durable store', step(async () => { + await h.restart() + const journal = await h.client.reconcileRecordInventory() + expect(submitted).toHaveLength(10) + expect(new Set(submitted).size).toBe(10) + for (const id of submitted) { + expect(journal.find(entry => entry.transactionId === id)).toMatchObject({ status: 'confirmed' }) + expect(journal.find(entry => entry.transactionId === id)!.transaction).toBeUndefined() + } + expect(journal.filter(entry => !['confirmed', 'rejected', 'cancelled'].includes(entry.status))).toEqual([]) + for (const token of h.tokens) await h.assertBalanced(token) + }), WRITE_TIMEOUT) +}) diff --git a/packages/shield-swap/test/integration/recordInventoryHarness.ts b/packages/shield-swap/test/integration/recordInventoryHarness.ts new file mode 100644 index 00000000..627776e0 --- /dev/null +++ b/packages/shield-swap/test/integration/recordInventoryHarness.ts @@ -0,0 +1,183 @@ +import { mkdir, open, readFile, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { expect } from 'vitest' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' +import { getConfirmedTransaction } from '@provablehq/veil-core' +import type { InventoryRecord, InventoryTarget, OwnedRecord, RecordAsset, RecordInventoryStore, RecordManagementConfig, Transaction } from '@provablehq/veil-core' +import { shieldSwapActions, tokenData, parseUnits, formatUnits, type TokenInfo } from '../../src/index.js' +import { fileBlindedIdentityStore } from '../../src/node.js' + +/** Enables only the explicitly requested testnet inventory write suites. */ +export const RUN_INVENTORY = process.env.VEIL_INVENTORY_E2E === '1' +/** Allows proving, confirmation, and scanner indexing for one write. */ +export const WRITE_TIMEOUT = 600_000 +/** Allows a bounded sequence of dependent joins and splits. */ +export const REBALANCE_TIMEOUT = 2_400_000 + +/** + * Holds discovered token metadata and the independently configured inventory target. + * @property asset Program-only descriptor resolved through the deployed ABI. + * @property minimum Smallest allowed lot in base units. + * @property target Record count and balanced distribution checked after maintenance. + */ +export type InventoryTestToken = TokenInfo & { asset: RecordAsset; minimum: bigint; target: InventoryTarget } + +/** + * Waits for a chain/scanner predicate without retrying any transaction submission. + * @param label Condition included in the timeout error. + * @param read Fetches the next chain or scanner observation. + * @param ready Accepts an observation when the expected state is visible. + * @param timeout Optional deadline in milliseconds; defaults to WRITE_TIMEOUT. + * @returns The first observation satisfying the predicate. + * @throws When a read fails or the deadline expires. + * @example + * const inventory = await poll('one record', scan, value => value.available.length === 1) + */ +export async function poll(label: string, read: () => Promise, ready: (value: T) => boolean, timeout = WRITE_TIMEOUT): Promise { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + const value = await read() + if (ready(value)) return value + await new Promise(resolve => setTimeout(resolve, 3000)) + } + throw new Error(`Timed out waiting for ${label}; retain the inventory and blinded-identity stores before retrying`) +} + +/** + * Extracts an explicit local input without putting record plaintext in assertion output. + * @param record Scanner record selected for the next testnet write. + * @returns Decrypted record literal accepted by the local signer. + * @throws When the provider omitted plaintext. + * @example + * const input = plaintext(inventory.available[0]!) + */ +export function plaintext(record: InventoryRecord): string { + const value = (record.record as OwnedRecord).recordPlaintext + if (!value) throw new Error('The live inventory suite requires locally decrypted records') + return value +} + +/** + * Opens a testnet account with durable reservations and swap recovery data. + * Reads VEIL_E2E_PRIVATE_KEY or VEIL_E2E_STATE_FILE; never generates a key or funds an account. + * The default journal lives under .veil/inventory-e2e/testnet/
/ and survives the run. + * @returns Connected clients and helpers; close the harness after the suite. + * @throws On invalid setup, unresolved journal entries, or insufficient testnet balances. + * @example + * const harness = await createInventoryHarness() + * try { await harness.scan(harness.tokens[0]!) } finally { await harness.close() } + */ +export async function createInventoryHarness() { + const state = process.env.VEIL_E2E_STATE_FILE + ? JSON.parse(await readFile(process.env.VEIL_E2E_STATE_FILE, 'utf8')) as { network?: string; privateKey?: string; apiUrl?: string } + : undefined + if (state && state.network !== 'testnet') throw new Error('VEIL_E2E_STATE_FILE must name a testnet session') + const privateKey = process.env.VEIL_E2E_PRIVATE_KEY ?? state?.privateKey + if (!privateKey) throw new Error('Set VEIL_E2E_PRIVATE_KEY or VEIL_E2E_STATE_FILE to an existing funded testnet account') + const count = Number(process.env.VEIL_INVENTORY_RECORDS ?? 6) + if (!Number.isInteger(count) || count < 3 || count > 16) throw new Error('VEIL_INVENTORY_RECORDS must be between 3 and 16') + const aleo = await loadNetwork('testnet') + const address = aleo.privateKeyToAccount(privateKey).address + const scope = JSON.stringify(['aleo:testnet', address]) + const directory = process.env.VEIL_INVENTORY_E2E_DIR ?? join(process.cwd(), '.veil', 'inventory-e2e', 'testnet', address) + await mkdir(directory, { recursive: true, mode: 0o700 }) + // The blinded store serializes one process only. Refuse concurrent suites for + // this account instead of racing counters or silently replacing test state. + const lockPath = join(directory, 'run.lock') + const lock = await open(lockPath, 'wx', 0o600) + await lock.writeFile(`${process.pid}\n`) + let store: Awaited> | undefined + const identities = fileBlindedIdentityStore(process.env.VEIL_BLINDED_STORE ?? join(directory, 'blinded.json')) + const connect = (journal?: RecordInventoryStore, options: Pick = {}) => aleo.createAleoClient({ + privateKey, + networkUrl: 'https://edge.provable.com/api/v2', + records: aleo.createRemoteScanner({ waitForSync: true, syncTimeoutMs: WRITE_TIMEOUT }), + provingMode: 'delegated', useFeeMaster: true, confirmationTimeout: WRITE_TIMEOUT, + ...(journal ? { recordManagement: { enabled: true, store: journal, chainId: 'aleo:testnet', ...options } } : {}), + }).walletClient.extend(shieldSwapActions({ + api: { baseUrl: process.env.VEIL_E2E_API_URL ?? state?.apiUrl }, blindedIdentities: identities, + })) + try { + store = await sqliteRecordInventoryStore(join(directory, 'inventory.sqlite')) + let client = connect(store) + await client.authenticateShieldSwap() + const unresolved = (await client.reconcileRecordInventory()).filter(entry => !['confirmed', 'rejected', 'cancelled'].includes(entry.status)) + if (unresolved.length) throw new Error(`Resolve ${unresolved.length} pending inventory operations in ${directory} before another run`) + const tokens = await Promise.all((['USDCx', 'ETH'] as const).map(async symbol => { + const token = await tokenData(client.api, symbol) + if (!token.underlyingProgram || !token.ammTokenProgram) throw new Error(`${symbol} lacks underlying/wrapper program metadata`) + const minimum = parseUnits(symbol === 'USDCx' + ? process.env.VEIL_INVENTORY_MIN_USDCX ?? '0.1' + : process.env.VEIL_INVENTORY_MIN_ETH ?? '0.000001', token.decimals) + if (minimum <= 0n) throw new Error(`${symbol} minimum must be positive`) + const target: InventoryTarget = { records: count, minRecordAmount: minimum, distribution: 'balanced', toleranceBps: 0 } + return { ...token, asset: { program: token.underlyingProgram }, minimum, target } + })) + const scan = (token: InventoryTestToken) => client.getRecordInventory({ asset: token.asset }) + for (const token of tokens) { + const inventory = await scan(token) + if (inventory.balance < token.minimum * BigInt(count + 1)) throw new Error(`Insufficient ${token.symbol} for ${count} records plus a trading margin`) + console.log(`inventory preflight: ${token.symbol}, ${inventory.available.length} records, ${formatUnits(inventory.balance, token.decimals)} ${token.symbol}`) + } + const confirm = async (id: string): Promise => { + const result = await poll(`confirmation of ${id}`, async () => { + try { return await getConfirmedTransaction(client, { id }) } + catch (error) { if ((error as { status?: number }).status === 404) return undefined; throw error } + }, result => result !== undefined) + if (result?.status !== 'accepted') throw new Error(`Transaction ${id} was not accepted`) + await client.reconcileRecordInventory() + return result.transaction as Transaction + } + const outputRecords = async (token: InventoryTestToken, id: string, expected: number) => { + const transaction = await confirm(id) + // Only outputs present in the accepted transaction may satisfy this step; + // a concurrent deposit or a stale scanner page cannot masquerade as one. + const commitments = new Set(transaction.execution?.transitions.flatMap(transition => transition.outputs ?? []) + .filter(output => output.type === 'record' || output.type === 'record_with_dynamic_id').map(output => output.id)) + const inventory = await poll(`${token.symbol} outputs of ${id}`, () => scan(token), inventory => + inventory.available.filter(record => commitments.has(record.record.commitment!)).length === expected) + return inventory.available.filter(record => commitments.has(record.record.commitment!)) + } + const assertBalanced = async (token: InventoryTestToken) => { + const inventory = await scan(token) + expect(inventory.available.length, `${token.symbol} record count`).toBe(count) + expect(inventory.reserved.length, `${token.symbol} pending inputs`).toBe(0) + expect(inventory.available.every(record => record.amount >= token.minimum), `${token.symbol} minimum`).toBe(true) + const amounts = inventory.available.map(record => record.amount).sort((a, b) => a < b ? -1 : a > b ? 1 : 0) + // Zero tolerance permits only integer rounding: equal lots differ by at most one base unit. + expect(amounts.at(-1)! - amounts[0]!, `${token.symbol} balanced amounts`).toBeLessThanOrEqual(1n) + expect(new Set(inventory.available.map(record => record.id)).size).toBe(count) + return inventory + } + const rebalance = async (token: InventoryTestToken) => { + const before = await scan(token) + const result = await client.rebalanceInventory({ token: token.id, target: token.target, + maxTransactions: 24, timeoutMs: WRITE_TIMEOUT, pollIntervalMs: 3000 }) + expect(result.status, result.reason).toBe('complete') + const after = await assertBalanced(token) + expect(after.balance, `${token.symbol} rebalance conserves value`).toBe(before.balance) + console.log(`inventory balanced: ${count} ${token.symbol} records, ${result.completedSteps} transactions`) + return result + } + return { + get client() { return client }, get store() { return store! }, identities, scope, tokens, count, directory, + scan, confirm, outputRecords, assertBalanced, rebalance, + peer: (options?: Pick) => connect(store, options), unmanaged: () => connect(), + // Reopen the actual SQLite file to test recovery across client/store lifetimes. + restart: async () => { + store!.close() + store = await sqliteRecordInventoryStore(join(directory, 'inventory.sqlite')) + client = connect(store) + await client.authenticateShieldSwap() + }, + close: async () => { store!.close(); await lock.close(); await unlink(lockPath) }, + } + } catch (error) { + store?.close(); await lock.close(); await unlink(lockPath) + throw error + } +} + +/** Captures the live client, durable stores, and independently checked inventory helpers. */ +export type InventoryHarness = Awaited> diff --git a/packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts b/packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts new file mode 100644 index 00000000..3679ffef --- /dev/null +++ b/packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts @@ -0,0 +1,131 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { InventoryRecord } from '@provablehq/veil-core' +import { formatUnits, parseUnits } from '../../src/index.js' +import { createInventoryHarness, plaintext, poll, RUN_INVENTORY, WRITE_TIMEOUT, REBALANCE_TIMEOUT, + type InventoryHarness, type InventoryTestToken } from './recordInventoryHarness.js' + +/** + * Maintains six equal records per token while trading USDCx to ETH and back. + * Each round claims both legs, restores the target, and verifies record identity, + * minimum amounts, exact token accounting, and durable reservation outcomes. + * Testnet writes require VEIL_INVENTORY_E2E=1 and an existing funded account. + * VEIL_INVENTORY_ROUNDS defaults to 3 (at most 5); VEIL_INVENTORY_SWAP_USDCX defaults to 0.1. + */ +const rounds = Number(process.env.VEIL_INVENTORY_ROUNDS ?? 3) +if (RUN_INVENTORY && (!Number.isInteger(rounds) || rounds < 1 || rounds > 5)) throw new Error('VEIL_INVENTORY_ROUNDS must be from 1 to 5') + +describe.runIf(RUN_INVENTORY).sequential('live USDCx/ETH inventory strategy', () => { + let h: InventoryHarness + let usdcx: InventoryTestToken + let eth: InventoryTestToken + let poolKey: string + let imports: Record + let tradeAmount: bigint + let failed = false + const runTransactionIds: string[] = [] + const consumed = new Set() + const step = (body: () => Promise) => async () => { failed = true; await body(); failed = false } + beforeEach(context => { if (failed) context.skip() }) + beforeAll(async () => { + h = await createInventoryHarness() + usdcx = h.tokens.find(token => token.symbol === 'USDCx')! + eth = h.tokens.find(token => token.symbol === 'ETH')! + tradeAmount = parseUnits(process.env.VEIL_INVENTORY_SWAP_USDCX ?? '0.1', usdcx.decimals) + if (tradeAmount <= 0n) throw new Error('VEIL_INVENTORY_SWAP_USDCX must be positive') + // Choose a live direct pool from API metadata, then require on-chain + // liquidity and trade controls before sending either token through it. + const pools = (await h.client.api.getPools({ limit: 100 })).data + .filter(pool => [pool.token0, pool.token1].includes(usdcx.id) && [pool.token0, pool.token1].includes(eth.id)) + let deepest = 0n + for (const pool of pools) { + const slot = await h.client.getSlot({ poolKey: pool.key }) + const controls = await h.client.getTradeControls({ poolKey: pool.key }) + if (slot && slot.liquidity > deepest && controls.tradeable) { poolKey = pool.key; deepest = slot.liquidity } + } + if (!poolKey) throw new Error('No tradeable USDCx/ETH pool with on-chain liquidity') + imports = await h.client.resolveDexImports({ tokenPrograms: [usdcx.ammTokenProgram!, eth.ammTokenProgram!] }) + console.log(`strategy: ${h.count} records per token, ${rounds} round trips, ${formatUnits(tradeAmount, usdcx.decimals)} USDCx per round`) + }, WRITE_TIMEOUT) + afterAll(async () => { await h?.close() }) + + /** Submits one swap and claims it before checking the independently predicted balances. */ + async function swapAndClaim(from: InventoryTestToken, to: InventoryTestToken, amount: bigint, input: InventoryRecord) { + const beforeIn = await h.scan(from) + const beforeOut = await h.scan(to) + expect(input.amount >= amount, `${from.symbol} covering input`).toBe(true) + expect(consumed.has(input.id), 'a previous swap input cannot be reused').toBe(false) + const handle = await h.client.swap({ poolKey, tokenInId: from.id, amountIn: amount, + tokenRecord: plaintext(input), slippageBps: 100, imports }) + consumed.add(input.id) + runTransactionIds.push(handle.transactionId) + expect(Boolean(handle.swapId), 'confirmed swap has a claim id').toBe(true) + const finalized = await poll('finalized swap output', () => h.client.getSwapOutput({ swapId: handle.swapId! }), value => value !== null) + // Claim before asserting economics so a failing assertion cannot strand an + // otherwise valid output. The durable identity store retains the handle if a + // service failure interrupts the claim itself. + const claim = await h.client.claimSwapOutput({ handle, imports }) + runTransactionIds.push(claim.transactionId) + const outputs = await h.outputRecords(to, claim.transactionId, 1) + await poll('consumed swap output', () => h.client.getSwapOutput({ swapId: handle.swapId! }), value => value === null) + expect(claim.amountOut).toBeGreaterThan(0n) + expect(claim.amountOut).toBe(finalized!.amount_out) + expect(claim.amountRemaining, 'small test trades must fill completely').toBe(0n) + expect(outputs[0]!.amount).toBe(claim.amountOut) + const afterIn = await h.scan(from) + const afterOut = await h.scan(to) + expect(afterIn.balance, `${from.symbol} debit`).toBe(beforeIn.balance - amount) + expect(afterOut.balance, `${to.symbol} credit`).toBe(beforeOut.balance + claim.amountOut) + expect(afterIn.available.some(record => record.id === input.id), 'spent nonce remains excluded').toBe(false) + const spend = (await h.store.list(h.scope)).find(entry => entry.transactionId === handle.transactionId)! + expect(spend.status).toBe('confirmed') + expect(spend.records).toContain(input.id) + expect((await h.identities.load()).find(identity => identity.swapId === handle.swapId)?.status).toBe('claimed') + return { amountOut: claim.amountOut, output: outputs[0]! } + } + + it('prepares both inventories with equal amounts above the configured minimum', step(async () => { + for (const token of h.tokens) await h.rebalance(token) + const inventory = await h.assertBalanced(usdcx) + // Keep the source record above its minimum after the forward swap. This + // makes the count change from each claim the reason maintenance is needed. + expect(inventory.available.every(record => record.amount - tradeAmount >= usdcx.minimum)).toBe(true) + }), REBALANCE_TIMEOUT * 2) + + for (let round = 1; round <= (Number.isInteger(rounds) && rounds > 0 && rounds <= 5 ? rounds : 3); round++) { + it(`round ${round}: swaps USDCx to ETH and back, then restores balanced records`, step(async () => { + const beforeUSDCx = await h.assertBalanced(usdcx) + const beforeETH = await h.assertBalanced(eth) + const forward = await swapAndClaim(usdcx, eth, tradeAmount, beforeUSDCx.available[0]!) + expect((await h.scan(eth)).available.length).toBe(h.count + 1) + // Spend the exact newly claimed ETH record on the return leg, preserving + // the six existing ETH lots and avoiding a second ambiguous selection. + const backward = await swapAndClaim(eth, usdcx, forward.amountOut, forward.output) + expect((await h.scan(usdcx)).available.length).toBe(h.count + 1) + const needsRepair = await h.client.planInventory({ token: usdcx.id, target: usdcx.target, maxTransactions: 24 }) + expect(needsRepair.steps[0]?.kind).toBe('join') + expect(needsRepair.steps[0]?.inputs.length).toBe(Math.min(h.count + 1, 16)) + expect(needsRepair.steps[0]?.joinProgram?.program).toMatch(/^aj_test_usdcx_stablecoin_/) + const repaired = await h.rebalance(usdcx) + expect(repaired.completedSteps).toBe(needsRepair.steps.length) + const unchangedETH = await h.rebalance(eth) + expect(unchangedETH.completedSteps).toBe(0) + expect((await h.assertBalanced(usdcx)).balance).toBe(beforeUSDCx.balance - tradeAmount + backward.amountOut) + expect((await h.assertBalanced(eth)).balance).toBe(beforeETH.balance) + console.log(`round ${round} complete: ${formatUnits(tradeAmount, usdcx.decimals)} USDCx → ${formatUnits(forward.amountOut, eth.decimals)} ETH → ${formatUnits(backward.amountOut, usdcx.decimals)} USDCx; ${h.count} balanced records per token`) + }), REBALANCE_TIMEOUT + WRITE_TIMEOUT * 4) + } + + it('retains confirmed checkpoints and both balanced inventories after reconnecting', step(async () => { + await h.restart() + const journal = await h.client.reconcileRecordInventory() + for (const id of runTransactionIds) { + const entry = journal.find(entry => entry.transactionId === id) + expect(entry?.status, `confirmed checkpoint for ${id}`).toBe('confirmed') + expect(entry?.transaction).toBeUndefined() + } + const confirmedInputs = journal.filter(entry => entry.status === 'confirmed').flatMap(entry => entry.records) + expect(new Set(confirmedInputs).size, 'no record was consumed by two confirmed transactions').toBe(confirmedInputs.length) + for (const token of h.tokens) await h.assertBalanced(token) + expect(runTransactionIds.length).toBe(rounds * 4) + }), WRITE_TIMEOUT) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93eecdc2..2979e4a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@provablehq/veil-aleo-sdk': specifier: workspace:^ version: link:../provable-sdk + '@provablehq/veil-core': + specifier: workspace:^ + version: link:../core packages/wallet-adapter: dependencies: diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md new file mode 100644 index 00000000..00b65c15 --- /dev/null +++ b/site/docs/guides/record-inventory.md @@ -0,0 +1,756 @@ +--- +title: Record management +description: Configure private record inventory, shared reservations, fee selection, and recovery in Veil. +--- + +# Record management + +This guide shows how to inspect private balances, prepare records for upcoming +transactions, and recover unfinished work in Veil. The examples use a local +signer on testnet. Connected wallets can also use record management when they +provide the [required record information](#interrupted-work-and-wallets). + +Aleo holds private tokens in **records**, each containing an individually +spendable amount. Spending a record consumes it; any change arrives in a new +record. An account's private balance is the sum of its unspent records, much +like a Bitcoin wallet's unspent outputs. + +A Shield Swap trade needs one record large enough for its input. Two trades +running at once need different records. These hypothetical USDCx inventories +all total 1 USDCx, but support different trades: + +| Available records | One 1 USDCx swap | Two simultaneous 0.5 USDCx swaps | +| --- | --- | --- | +| One record holding 1 USDCx | Ready | Split the record first. | +| Two records holding 0.5 USDCx each | Join the records first. | Ready | +| Records holding 0.4 and 0.6 USDCx | Join the records first. | Redistribute the amounts first. | + +A **join** consumes several records and produces one larger record. A **split** +consumes one record and produces two smaller ones. Both are on-chain +transactions. Veil can plan these operations before submitting them. + +Record management also reserves inputs while a transaction is in progress. +A reservation tells other clients sharing the store that a record is already +in use. The store's **journal** tracks those reservations and transaction +outcomes, so unfinished work can be checked after an interruption. + +## Enable record coordination + +Start with a client that can sign transactions and read the account's records. +This example reads an existing testnet key from `ALEO_PRIVATE_KEY`; it does not +create or fund an account. The remote scanner finds unspent records, while the +inventory store tracks which of those records are in use by this application. + +Wallet clients already include the inventory methods. Reservations and recovery +are **off by default**, including after `.extend(recordActions())` or +`.extend(shieldSwapActions())`. Set `recordManagement.enabled` when creating the +client to enable them: + +```ts +import { memoryRecordInventoryStore } from '@provablehq/veil-core' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' + +const privateKey = process.env.ALEO_PRIVATE_KEY +if (!privateKey) throw new Error('Set ALEO_PRIVATE_KEY') + +const aleo = await loadNetwork('testnet') +const store = memoryRecordInventoryStore() +const { walletClient: client } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, +}) + +// Read the available balance without signing or submitting a transaction. +const inventory = await client.getRecordInventory({ asset: { program: 'credits.aleo' } }) +console.log('Available microcredits:', inventory.balance.toString()) +console.log('Available records:', inventory.available.length) +console.log('Reserved records:', inventory.reserved.length) +``` + +`inventory.available` contains records that can be selected now; +`inventory.reserved` contains records held by journal entries, including spent +inputs still reported by the scanner. `inventory.balance` sums only the +available records. Reading this inventory does not reserve them. + +The example keeps its journal in memory. Follow the [storage setup](#share-a-store-and-retain-it-across-restarts) +below before submitting transactions that must remain recoverable after a +restart. Journals store record identifiers and transaction progress, not private +keys or decrypted record contents. + +`createClient`, `createWalletClient`, `aleo.createAleoClient`, and +`createDevnodeClient` accept `recordManagement`. Core provides the interfaces +without requiring Node, a database, or a particular wallet. The Aleo SDK supplies +the scanner and local or delegated prover. A connected wallet supplies its own +records and proving; see [wallet requirements](#interrupted-work-and-wallets). + +### Configuration and defaults + +| Setting | Default | When to set it | +| --- | --- | --- | +| `enabled` | Disabled when `recordManagement` is omitted | Set `true` to reserve inputs and retain transaction checkpoints. It does not start background maintenance. | +| `store` | A separate `memoryRecordInventoryStore()` for each enabled client when omitted | Configurable through any `RecordInventoryStore` implementation, including the optional SQLite adapter or a custom backend. Share one store across clients using the same inventory. See [storage setup](#share-a-store-and-retain-it-across-restarts) for persistence and recovery. | +| `chainId` | The transport's network | Use the same identity for participants on one chain. Give each custom or local chain a distinct identity. Required if the transport has no network. | +| `maxFeeMicrocredits` | No limit | Bound the total network fee of each transaction before broadcast. Requires a local signer and a prover that supports checkpoints. | +| `tokenJoin` | Helpers selected by asset and network | Set `false` for native pairwise joins, or configure a reviewed dynamic ARC helper. See [batch joins](#batch-joins). | + +With `enabled: false`, no journal is created and fee limits are ignored. Chain +identity and join-routing options can still affect read-only plans and explicit +joins. Enabling coordination does not automatically join records during a swap, +split balances, or launch a worker. Call the maintenance methods when needed. + +Configure record management before attaching action decorators. Rebuild the +client when changing networks; an in-progress operation checks that its account +and chain remain unchanged. Clients sharing reservations MUST use the same +`chainId`. For example, `'testnet'` and `'aleo:testnet'` create separate groups +of reservations even when both clients contact the same testnet node. + +### Share a store and retain it across restarts + +Two calls can scan the same record before either starts proving. The store +resolves that race by acquiring all inputs for one transaction in a single +operation. If any input is already reserved, it acquires none of them. + +The lock covers that acquisition only. Once two calls have reserved different +inputs, they can prove concurrently: + +```text +Call A: scan -> reserve record 1 -> prove ---------> submit -> confirm +Call B: scan -> reserve record 2 -> prove -> submit ---------> confirm + | + shared inventory store +``` + +The journal retains the reservations after the lock is released. A record's +nonce identifies it across scanner and wallet responses. Reads and plans do +not acquire nonces; execution does. + +Choose storage according to which clients must coordinate and whether the +journal must survive a restart: + +| Store | Coordination | Restart behavior | +| --- | --- | --- | +| `memoryRecordInventoryStore()` | Clients sharing the same object in one process | Entries disappear when the process exits. | +| `sqliteRecordInventoryStore(path)` | Participating processes opening the same SQLite database | Reservations and prepared transactions remain on disk. Node 22.13+ only. | +| Custom `RecordInventoryStore` | Determined by its implementation | The implementation must persist mutations before resolving them if recovery is required. | + +The Node SQLite adapter is an optional export of +`@provablehq/shield-swap-cli/storage`; install that package to use it. It is not +a dependency of core or the Shield Swap SDK. Replace the memory store above +with a stable path scoped to the account and network: + +```ts +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' + +const store = await sqliteRecordInventoryStore('.veil/testnet/trading-account/inventory.sqlite') +// Pass store in recordManagement when constructing every participating client. +// Call store.close() only after all of those clients have finished their work. +``` + +For two local signers in the same process, pass that same `store` object to +both client configurations. Both sign for the same account and share its +reservations: + +```ts +const createParticipant = () => aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, +}).walletClient + +const firstClient = createParticipant() +const secondClient = createParticipant() +// Writes through either client see reservations made through the other. +``` + +For a custom backend, implement `RecordInventoryStore` with these guarantees: + +| Operation | Required behavior | +| --- | --- | +| `acquire` | Atomically check and acquire every requested nonce within the account/chain scope, or acquire none. A separate `list()` followed by an insert leaves a race. | +| `acquire` and `update` | Persist the change before resolving the promise when recovery must survive a restart. | +| Selection after confirmation | Continue excluding consumed inputs even if a scanner still reports them as unspent. | +| Selection after rejection | Release execution inputs, but keep a consumed private fee record excluded. | +| Uncertain submission | Keep reservations until the outcome is resolved. Do not release them on a timer. | + +Coordination applies only to clients using this store and scope. Raw submissions, +other wallets, or traders using a different database can still spend the same +records. Shield Swap also needs a separate store for blinded identities and +claim handles; its [concurrent setup](./shield-swap.md#concurrent-swaps) covers both. + +## Use the inventory methods + +Choose an action based on the next spend. For example, a single transfer may +need one larger record, while a concurrent trading batch needs several records. +The methods below operate on `client`, configured above: + +| Method | Effect | +| --- | --- | +| `client.getRecordInventory({ asset })` | Scans all pages and reports available records, reserved records, and available balance. | +| `client.joinRecords({ asset, records })` | Submits one native or helper join using the exact supplied records. Returns a transaction id before confirmation. | +| `client.splitRecord({ asset, record, amount })` | Submits a native split. The first output holds `amount` in base units. Returns before confirmation. | +| `client.autoJoin({ asset, minAmount? })` | Selects records and runs as many joins as needed to obtain a sufficient record, or one consolidated record if no threshold is supplied. | +| `client.planRecordInventory({ asset, target, maxTransactions? })` | Reads the inventory and returns an account/chain-bound plan without signing or reserving inputs. | +| `client.rebalanceRecordInventory({ plan, ... })` | Executes the reviewed plan, waiting for confirmation and scanner visibility before each dependent step. | +| `client.reconcileRecordInventory({ rebroadcast? })` | Checks pending transactions on chain; optionally rebroadcasts their saved proofs without rebuilding them. | + +Reading, planning, and individual joins or splits remain available with +coordination disabled. Rebalance and reconciliation require it. `autoJoin` +requires it when joins are needed; an existing sufficient record returns +`complete` with zero transactions. + +### Join only enough for a spend + +Suppose a transfer needs one credit, but the account holds separate 0.4 and +0.6 credit records. `autoJoin` selects records and waits for the joined output +to become available. If a record already holds at least one credit, it returns +without submitting a join. + +Amounts use integer base units: one credit is 1,000,000 microcredits. Request +that minimum explicitly: + +```ts +const result = await client.autoJoin({ + asset: { program: 'credits.aleo' }, + minAmount: 1_000_000n, + maxTransactions: 8, + timeoutMs: 120_000, + pollIntervalMs: 2_000, + maxFeeMicrocredits: 10_000_000n, +}) +if (result.status === 'interrupted') { + console.error(result.reason, result.transactionIds) + throw new Error('Resolve the interrupted joins before spending') +} +``` + +When a join is needed, `autoJoin` selects the largest records until their sum +covers `minAmount`. It prefers a compatible [batch helper](#batch-joins) and +waits for each output needed by a later step. Omitting `minAmount` consolidates +all eligible, unreserved records. The configured fee payment mode stays unchanged. + +### Join or split exact records + +Use `joinRecords` when the caller has already selected the inputs. This example +joins exactly two available credits records through the native program: + +```ts +const credits = { program: 'credits.aleo' } +const beforeJoin = await client.getRecordInventory({ asset: credits }) +const inputs = beforeJoin.available.slice(0, 2) +if (inputs.length !== 2) throw new Error('Two available credits records are required') + +const joinId = await client.joinRecords({ + asset: credits, records: inputs, joinProgram: false, +}) +console.log('Submitted join:', joinId) +``` + +`joinRecords` returns a transaction id before confirmation. Use `autoJoin` when +the next operation must wait for the exact joined record to appear in the scanner. +For more than two records in one transaction, omit `joinProgram` and supply a +count supported by an available helper; `autoJoin` handles larger sequences. + +A split creates two positive outputs. For native credits, splitting a 1-credit +record with `amount: 400_000n` produces 0.4 and 0.59 credits. The split itself +deducts 10,000 microcredits, separately from the transaction fee: + +```text +1,000,000 microcredits + | + native split + | + +--- 400,000 microcredits (requested first output) + +--- 590,000 microcredits (remaining output) + +--- 10,000 microcredits (split deduction) +``` + +To make that first output from any sufficiently large available record: + +```ts +const beforeSplit = await client.getRecordInventory({ asset: { program: 'credits.aleo' } }) +const amount = 400_000n +const deduction = 10_000n +const source = beforeSplit.available.find((record) => record.amount > amount + deduction) +if (!source) throw new Error('No record can produce two positive outputs') + +const splitId = await client.splitRecord({ + asset: { program: 'credits.aleo' }, record: source, amount, +}) +console.log('Submitted split:', splitId) +``` + +This also returns before confirmation. Wait for confirmation and scanner +visibility before spending either output. The [planner](#plan-joins-and-splits) +handles those dependencies when preparing several records. + +## Select the record and amount field + +The `asset` argument identifies which program's records to manage. Most callers +need only `{ program: 'credits.aleo' }` or a token's program id. Core reads the +deployed `join` and `split` signatures to find the spendable record and its +amount type: + +| Asset | Default record | Default amount field | +| --- | --- | --- | +| `credits.aleo` | `credits` | `microcredits` | +| ARC-20 or ARC-22 token | `Token` | `amount` | +| Another compatible program | Record accepted by its native join | `amount`, then `microcredits` | + +`standard` is optional. When supplied, it is checked against the program. +For a custom program with different names, supply `recordName` or `amountField`. +For example, a deployed `points.aleo` program might join `Balance` records whose +balance is stored in `points`: + +```ts +const asset = { + program: 'points.aleo', + recordName: 'Balance', + amountField: 'points', +} +const inventory = await client.getRecordInventory({ asset }) +``` + +The override applies to scans, wallet input requests, joins, splits, and plan +amounts. The record MUST match the native `join`/`split` inputs and outputs, and +the field MUST be an unsigned integer matching the split amount argument. +An override naming an undeclared record or field is an error; core does not +substitute another balance field. + +Inspect `inventory.asset` to see the resolved record name, amount field, and +integer type. Only eligible fungible records are included: compliance records, +registry records, and records restricted to a particular recipient are excluded. + +All inventory amounts use `bigint` in base units. Credits use u64; ARC tokens +use u128. Custom fields use their declared unsigned width: u8, u16, u32, u64, +or u128. Joins that overflow that width are invalid. + +## Plan joins and splits + +Use a plan when several operations must run in order. For example, preparing +six records may require a join followed by splits, with each split waiting for +the preceding transaction's output. Planning shows that sequence without signing, +reserving inputs, or submitting transactions. + +The target describes the desired inventory after those operations: + +| Option | Default | Meaning | +| --- | --- | --- | +| `target.records` | Required | Desired count, from 1 to 1,000. | +| `target.minRecordAmount` | `1n` | Minimum base-unit amount in each record. | +| `target.distribution` | `'preserve'` | Retain suitable existing records. Choose `'balanced'` to aim for equal amounts. | +| `target.toleranceBps` | `1000` (10%) | Allowed deviation from equal amounts when balancing, from 0 to 10,000. Use 0 for equal amounts, allowing integer rounding. | +| `maxTransactions` | `100` | Maximum joins and splits in the plan, up to 10,000. | + +The planner keeps suitable records and splits directly where possible; otherwise +it consolidates before splitting. It does not guarantee the fewest possible +transactions. A required intermediate join that would overflow the asset's +amount type causes planning to fail. + +### Review and execute a plan + +Prepare six records with at least one credit each, and inspect the required +operations before spending: + +```ts +const plan = await client.planRecordInventory({ + asset: { program: 'credits.aleo' }, + target: { + records: 6, minRecordAmount: 1_000_000n, + distribution: 'balanced', toleranceBps: 0, + }, + maxTransactions: 24, +}) +console.table(plan.steps.map((step) => ({ + operation: step.kind, inputs: step.inputs.length, outputs: step.outputs.length, + helper: step.joinProgram?.program ?? 'native', +}))) +console.log('Intrinsic split deductions:', plan.deduction.toString()) +``` + +The target applies to the entire available asset inventory. Reserved records +remain outside it. Equal records can differ by one base unit because amounts +are integers. A plan is a snapshot, so execution rechecks its inputs and stops +if another operation has consumed or reserved them. + +After reviewing the plan, execute that same object without modifying its steps: + +```ts +const result = await client.rebalanceRecordInventory({ + plan, + timeoutMs: 120_000, + pollIntervalMs: 2_000, + maxFeeMicrocredits: 10_000_000n, +}) +if (result.status === 'interrupted') { + console.error(result.reason, result.transactionIds) + // Inspect confirmations before creating another plan. Accepted steps remain. + await client.reconcileRecordInventory() +} +``` + +Execution submits real transactions. Limits apply at different points: + +| Limit | What it controls | +| --- | --- | +| Client `recordManagement.maxFeeMicrocredits` | Maximum network fee for each transaction. | +| Action `maxFeeMicrocredits` | Total network fees across the maintenance run. Both fee limits apply when set. | +| Plan `maxTransactions` | Number of joins and splits, including batch joins. | +| Action `timeoutMs` | Confirmation and scanner visibility for each step. Proving time is additional. | +| Action `signal` | Stops further work after cancellation; it cannot cancel a transaction already submitted. | + +Native credits split deductions are separate from network fees. Review +`plan.deduction` as well as the fee budget. If execution is interrupted, accepted +steps remain on chain; follow [recovery](#recover-interrupted-work) before +creating a replacement plan. + +### Batch joins + +A native join takes two records. Joining sixteen records that way needs fifteen +transactions. A batch helper calls the native joins within one transaction, so +Veil prefers a compatible helper when more than two records need joining. + +For example, consolidating credits uses these sequences when the helper is +available: + +| Starting inventory | Transactions | +| --- | --- | +| 2 records | One native `credits.aleo/join`. | +| 16 records | One helper `join_16`. | +| 17 records | One helper `join_16`, then a native join of its confirmed output and the remaining record. | + +The exported `recordJoinPrograms` map lists helpers by network and source asset. +An entry for a specific program takes precedence over an ARC default: + +| Asset | Mainnet helpers | Testnet helpers | Records per call | +| --- | --- | --- | --- | +| `credits.aleo` | `autojoin_credits_*` | `autojoin_credits_*` | 2 to 16 | +| USAD | `aj_usad_stablecoin_*` | `aj_test_usad_stablecoin_*` | 2 to 16 | +| USDCx | `aj_usdcx_stablecoin_*` | `aj_test_usdcx_stablecoin_*` | 2 to 16 | +| Other ARC-20/ARC-22 tokens | `main_aj_arc20_2_15.aleo` | `test_aj_arc20_2_15.aleo` | 2 to 15 | + +For each `*`, the helper suffixes are `2_10.aleo`, `11_14.aleo`, and +`15_16.aleo`. Dedicated stablecoin entries apply to `usad_stablecoin.aleo` and +`usdcx_stablecoin.aleo` on mainnet, and their `test_` variants on testnet. +Each helper exposes `join_N` for the input counts in its range. + +Core checks deployed function signatures before planning and again before +submission. Missing or incompatible helpers and unknown networks use native +pairwise joins. Lookup errors other than a missing program are reported to the +caller. Custom record or field selections use native joins unless they match +the helper's record and amount interface. + +The planner selects the largest compatible batch that preserves the target +count and fits the amount width. It saves that choice in the plan so execution +uses the reviewed helper. + +Keep the default helper lookup, disable it, or select a dynamic ARC router at +client creation: + +| `recordManagement.tokenJoin` | Behavior | +| --- | --- | +| Omitted | Select helpers by asset and network; use native joins for two records. | +| `false` | Use native pairwise joins for every asset. | +| `{ program, maxRecords? }` | Use that dynamic ARC router for token joins, including two-record joins. `maxRecords` defaults to 15 and accepts 2 to 15. Credits keep their automatic helper selection. | + +For example, to force native joins on testnet: + +```ts +const { walletClient: nativeJoinClient } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet', tokenJoin: false }, +}) +``` + +Helper selection works with `enabled: false` for planning and individual joins. +It does not enable coordination or execute a plan. Custom clients can also use +`recordActions({ tokenJoin })` when no record management settings were supplied +during client creation. `joinRecords({ asset, records, joinProgram })` can +select a helper directly using its program, supported counts, and input mode; +`joinProgram: false` uses a native join for that call. Use `autoJoin` when the +records require more than one transaction. + +The helper determines how join inputs are constructed: + +| Helper | Inputs passed by Veil | +| --- | --- | +| Credits or dedicated stablecoin helper | The selected records. | +| Dynamic ARC helper | The underlying program identifier, such as `'test_arc20_eth'`, followed by the selected records. The token program is also supplied as a dynamic import. | + +The Aleo SDK resolves transitive imports for local and delegated proving. +Wallet adapters must support these inputs and pin records from the underlying +token program. + +A managed batch reserves every input together. Recovery entries retain the +source asset, fees, and proved transaction. Before a dependent step proceeds, +Veil waits for the encrypted record produced by the final native join inside +the helper. It uses that record's commitment, rather than the separate id of +the helper's external or dynamic return. + +## Coordinate with Shield Swap + +Attach `shieldSwapActions()` to the managed wallet client. The DEX methods +resolve symbols and token ids to the underlying asset that holds spendable +records. Maintenance operates on that program even when swaps use an AMM wrapper. + +```ts +import { shieldSwapActions, parseUnits } from '@provablehq/shield-swap-sdk' + +const dex = client.extend(shieldSwapActions({ api: {} })) +await dex.authenticateShieldSwap() +const token = await dex.tokenData('USDCx') +const plan = await dex.planInventory({ + token: token.id, + target: { records: 6, minRecordAmount: parseUnits('0.1', token.decimals), distribution: 'balanced' }, + maxTransactions: 24, +}) +// Inspect plan.steps. When approved, execute this exact plan: +// await dex.rebalanceRecordInventory({ plan }) +``` + +`dex.rebalanceInventory({ token, target })` is a convenience method that creates +and executes a fresh plan. Use `dex.rebalanceRecordInventory({ plan })` when +executing a plan already inspected. Neither extending the client nor calling a +planner starts automatic maintenance. + +Managed `writeContract`, `executeTransaction`, and `executeContract` calls +reserve their explicit record inputs before proving. Trading and maintenance +must use the same store and chain identity to see each other's reservations. + +### Concurrent swaps + +Six simultaneous USDCx sales need six input records and six distinct blinded +identities. A blinded identity receives a swap's proceeds until a claim collects +them. The two stores therefore have different responsibilities: + +| Shared state | Purpose | +| --- | --- | +| Record inventory store and matching `chainId` | Prevent two participating calls from acquiring the same input record. | +| One blinded identity store instance | Allocate a separate identity to each swap and retain its claim information. | + +With local signers and no explicit `tokenRecord`, `swap` and `swapMultiHop` scan +once for sufficient records, smallest first. A reservation conflict advances to +the next candidate before proving. Each record nonce is tried once; exhausting +the candidates fails the call. Fixed `tokenRecord` inputs stay fixed. + +The trade amount, minimum output, blinded identity, and swap nonce stay unchanged +during selection. Each attempt checks the account, network, and quote expiry. +Proving, submission, and confirmation errors do not trigger another swap. +A failed execution-input reservation produces `RecordReservationConflictError`; +a missing private fee record produces a separate error. + +Follow the [concurrent swaps walkthrough](./shield-swap.md#concurrent-swaps) for +a complete client setup, a reservation diagram, and code that inspects every +swap and claim. Its [runnable example](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) +prepares six records and includes preview and recovery steps. + +Run one process for an account with the bundled blinded identity stores. They +coordinate counters only within a process. Several clients in that process must +share the same store instance or supply distinct explicit identities. SQLite can +coordinate record reservations across processes, but it does not coordinate +blinded identity allocation. + +## Recover interrupted work + +A timeout can occur after a transaction has reached the network. Repeating the +original call at that point can submit a second operation. Recovery starts by +checking what happened to the saved transaction. + +### 1. Reopen the journal and inspect outcomes + +Recreate the client with its original durable store, account, and `chainId`. +An in-memory journal cannot recover entries lost when the process exited. +Then reconcile before submitting anything new: + +```ts +const journal = await client.reconcileRecordInventory() +const unresolved = journal.filter((entry) => + !['confirmed', 'rejected', 'cancelled'].includes(entry.status), +) +console.table(unresolved.map((entry) => ({ + operation: entry.id, transactionId: entry.transactionId, status: entry.status, + attempts: entry.rebroadcastAttempts ?? 0, error: entry.lastRebroadcastError, +}))) +``` + +Reconciliation reads the chain and updates the journal. It does not submit +transactions by default. Any entries shown in `unresolved` still need attention; +a missing transaction or HTTP 404 does not prove rejection. + +### 2. Resend a saved transaction when needed + +If a prepared transaction is still missing and inspection calls for resending +it, explicitly enable rebroadcast: + +```ts +await client.reconcileRecordInventory({ rebroadcast: true }) +// Check confirmations again later; rebroadcast acceptance is not settlement. +``` + +Rebroadcast sends the exact saved transaction with its existing proof and id. +It neither rebuilds it nor resumes a partially completed plan. Check again on +a later poll; accepting a broadcast is not confirmation. Entries without a +saved transaction cannot be recovered by this option. + +A failed rebroadcast leaves the transaction pending and records its error in +`lastRebroadcastError`. `rebroadcastAttempts` counts recovery attempts; it is +absent until the first attempt. Reconciliation continues checking later entries, +so one failed broadcast does not hide confirmations for other transactions. +A successful rebroadcast or confirmed outcome clears the last error. + +Inspect failures before deciding whether to retry: + +```ts +const entries = await client.reconcileRecordInventory({ rebroadcast: true }) +for (const entry of entries) { + if (entry.lastRebroadcastError !== undefined) { + console.error(entry.transactionId, entry.lastRebroadcastError) + } +} +``` + +Errors from confirmation lookups, except a transaction-not-found response, and +storage failures still throw. A broadcast error does not release execution or +private-fee records. Keep the journal until the chain outcome is known. + +### 3. Plan against the confirmed inventory + +Once uncertain submissions are resolved, read the inventory again and make a +new plan for the remaining work. Completed joins and splits have already changed +the available records. For Shield Swap, also [recover unfinished claims](./shield-swap.md#selection-failures-and-recovery) +before starting another batch. + +The journal status explains why an input remains unavailable: + +| Journal status | Record treatment | +| --- | --- | +| `reserved` | Inputs are held before proving. A local failure known to precede submission can cancel the reservation. | +| `prepared` / `submitted` | Inputs remain held until the chain outcome is known. | +| `confirmed` | Consumed inputs remain excluded from stale scanner results. | +| `rejected` | Execution inputs are released; a charged private fee record stays excluded. | +| `cancelled` | Inputs are available again because the local operation did not submit. | + +## Interrupted work and wallets + +The bundled Aleo SDK adapters save a local or delegated transaction before +broadcasting it. This gives the journal a proof and transaction id to recover. +For delegated proving, they request `broadcast: false` and submit only after +the checkpoint has been saved. + +A custom `ProvingConfig.execute` adapter MUST await the `transaction-prepared` +progress callback before broadcasting. That callback persists the transaction +and checks the fee ceiling. Broadcasting first defeats both checks. + +Connected wallets control their own records and submission. Record management +therefore needs enough information from the wallet to identify the exact inputs +and follow their outputs: + +| Wallet capability | Why it is needed | +| --- | --- | +| Granted plaintext, or fields including `$nonce`, the amount, record name, and `recipient_bound` where applicable | Identify and validate records without relying on ciphertext alone. | +| A pinned record `uid` when plaintext is unavailable | Spend the record already reserved, rather than let the wallet select another. | +| Output `commitment` metadata or a `$commitment` grant | Find the exact new record needed by the next join or split. | + +Rebalance checks commitment metadata before its first submission. Missing +metadata returns an interrupted result with no transactions submitted. A lost +wallet response without a transaction id leaves the reservation held for +external reconciliation. + +Wallet submission cannot currently enforce a fee ceiling before broadcast or +pin a private fee record. Managed wallet execution therefore rejects fee budgets +and requires public fees or FeeMaster. Local signers support the private fee +selection below. A wallet rebalance with a run-level or client-level fee ceiling +throws before scanning if the plan contains transactions. An already satisfied +plan still completes without a scan or submission. + +## Private fees + +A private fee spends a `credits.aleo` record separately from the transaction's +execution inputs. Even when the transaction itself spends credits, its fee needs +a different record. Set `privateFee: true` on a managed local-account write to +have the client select and reserve that fee record. + +Selection uses the estimated base fee plus any priority fee: + +| Available credits records | Selection | +| --- | --- | +| A supplied `feeRecord` is unreserved, sufficient, and separate from the execution inputs | Use the supplied record. | +| The supplied record is reserved, insufficient, or already an execution input | Select the smallest other available record that covers the fee. | +| No `feeRecord` is supplied | Select the smallest available record that covers the fee. | +| All sufficient records are reserved or being spent by this transaction | Fail before proving. Records are not automatically joined and payment does not switch to public fees or FeeMaster. | + +For example, this transfer sends one credit from the account's **public** +balance and pays its network fee from a separate private record. Set +`ALEO_RECIPIENT` to the recipient's address: + +```ts +const recipient = process.env.ALEO_RECIPIENT +if (!recipient) throw new Error('Set ALEO_RECIPIENT') + +const transferId = await client.writeContract({ + program: 'credits.aleo', + function: 'transfer_public', + inputs: [recipient, '1000000u64'], + privateFee: true, + // Omit feeRecord to select an available credits record automatically. +}) +console.log('Submitted transfer:', transferId) +``` + +To prefer a particular record, pass its plaintext as `feeRecord`. An unavailable +preferred record is replaced according to the table above. The client reserves +the fee record and execution inputs together. If another call takes the fee +record first, selection tries the next candidate. + +Before broadcast, the manager checks that the proved transaction spends the +reserved fee record and that its fee fits the balance and configured ceiling. +The journal stores the record's nonce and the proved transaction, without saving +record plaintext. Explicit private payment takes precedence over FeeMaster. + +A rejected execution can still consume its private fee. Reconciliation releases +the execution inputs while retaining the spent `feeRecordId`. Timeouts retain +all reservations until the outcome is known. + +The bundled local and delegated adapters honor the selected `feeRecord`. +Custom local provers also need `estimateExecutionFee` and MUST await the +prepared-transaction callback before broadcasting. + +For a supervised background process with policies, cooldowns, budgets, and a +restart journal, use the [Shield Swap inventory commands](../packages/shield-swap-cli.md#record-inventory). + +## Migrate a recordActions configuration + +Move `store`, `chainId`, `maxFeeMicrocredits`, and `tokenJoin` into the client's +`recordManagement` configuration and set `enabled: true` to keep reservations +and recovery enabled. `createClient`, `createWalletClient`, +`aleo.createAleoClient`, and `createDevnodeClient` accept this option. Wallet +clients include the inventory methods without a `recordActions` extension. + +For example, configure a fee ceiling and native joins on the client itself: + +```ts +const { walletClient: managedClient } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { + enabled: true, + store, + chainId: 'aleo:testnet', + maxFeeMicrocredits: 10_000_000n, + tokenJoin: false, + }, +}) +// managedClient already has getRecordInventory, autoJoin, and the other actions. +``` + +For an application still passing settings to the decorator: + +| Option passed to `recordActions()` | Effect | +| --- | --- | +| `store`, `maxFeeMicrocredits` | Accepted but ignored. Configure them on the client to reserve records, enforce fees, and retain recovery entries. | +| `chainId`, `tokenJoin` | Apply only when the client has no record management configuration. Client settings take precedence. | + +Existing assets with `standard` remain valid. New callers can supply only the +program or add record/field overrides. To keep native pairwise joins after +upgrading, set `tokenJoin: false`; omitting it now selects available helpers. diff --git a/site/docs/guides/shield-swap.md b/site/docs/guides/shield-swap.md index c0a938d3..4f99d492 100644 --- a/site/docs/guides/shield-swap.md +++ b/site/docs/guides/shield-swap.md @@ -62,12 +62,7 @@ needs no credentials. See [`createRemoteScanner`](/api/provable-sdk/createRemoteScanner) for the scanner's registration behavior. -The composed client has two surfaces, split by provenance. Chain-direct -reads and writes sit flat on the client (`client.getPool`, `client.swap`) — -these values come from the node and gate money movement. The off-chain DEX -API is namespaced under `client.api` (`client.api.getPools`), so a call site -always shows which world a value came from. Both default to -`shield_swap_v3.aleo` and the Provable API; override either with +To use a different contract or API deployment, pass `shieldSwapActions({ program, api: { baseUrl } })`. In a browser dApp, the connected wallet replaces all of this configuration: @@ -78,174 +73,582 @@ examples on [`swap`](/api/shield-swap/swap) show the connect-time setup, including the `SHIELD_SWAP_ALGORITHM_GRANTS` the wallet needs to derive blinded identities. -## Authenticating with the DEX API +## The Shield Swap API -Most DEX API endpoints beyond pool and token discovery — routes, swaps, -positions, balances, fee tiers, candles — require a bearer credential. The -account signs a challenge once per session: +The Shield Swap API is an HTTP service for discovering pools and tokens, +finding swap routes, and reading indexed trading data. It supplies information +that an application would otherwise need to collect from chain history or +calculate with its own route finder. Veil uses this service for pool discovery +and quotes. -```ts -await client.authenticateShieldSwap() -const route = await client.api.getRoute({ token_in, token_out }) -``` +The API works alongside Aleo nodes. Nodes provide live contract state and +transaction results; the API provides discovery and trade estimates. The client +uses each through the following methods: + +| Method | Source | Purpose | +| --- | --- | --- | +| `client.api.getPools()`, `client.api.getTokens()` | Shield Swap API | Discover available pools and token metadata. | +| `client.quote()` | Shield Swap API | Find a route and estimate its output; calculate a minimum output from the chosen slippage tolerance. | +| `client.getPool()`, `client.getSlot()` | Aleo node | Read the pool's configuration and current trading state. | +| `client.swap()` | Aleo node and prover | Validate the quoted route's pools and submit the swap transaction. | +| `client.waitForSwapOutput()`, `client.claimSwapOutput()` | Aleo node; a prover for the claim | Read the finalized output, then submit a transaction to collect it. | -The session lasts about 24 hours and renews itself: the signer is retained, -so an expired session re-authenticates and retries on the next gated call. +API endpoint methods live under `client.api`. Higher-level methods can use those +endpoints internally: `client.quote()` calls the API's route endpoint. The +quote's output is an estimate; the swap transaction enforces its minimum output +against the pool state at execution. Read the actual proceeds from the chain +before claiming them. -A process that should not sign on every boot — a bot, CI, a server — mints a -long-lived API token (`ss_…`) once and passes it at construction instead: +Pool and token discovery, fee-tier reads, and public market data need no login. +Route quotes and account-specific data require authentication. Direct chain +reads such as `client.getSlot()` work without API credentials. + +### Authenticate the account + +Authentication signs an API challenge to prove control of the account, without +submitting a transaction or paying a network fee. Using the client configured +above: ```ts -// One-time provisioning; the secret is shown only once. await client.authenticateShieldSwap() -const { token } = await client.api.createApiToken({ name: 'trading-bot' }) - -// Every run after that — no handshake, no signer needed for API reads. -const bot = walletClient.extend(shieldSwapActions({ api: { apiToken: token } })) ``` -Managing tokens (`createApiToken`, `listApiTokens`, `revokeApiToken`) always -requires a fresh session JWT; the tokens themselves cover data and trading -endpoints only. +The client retains the session credential and attaches it to requests that need +it. By default, if an authenticated request returns 401 for an expired session, +the retained signer authenticates again and the client retries that request +once. A failed renewal is returned to the caller. -Authentication is the first of two gates. The account must also have -redeemed a referral code (the invite codes Shield Swap distributes), or gated -endpoints return 403 `redeem an invite code to unlock access`: +### Enable access with an invite code + +An authenticated account also needs Shield Swap access to request routes and +other restricted data. Check whether the account already has access before +redeeming an invite code: ```ts -await client.authenticateShieldSwap() if (!(await client.api.getReferralStatus()).has_access) { - await client.api.redeemReferralCode(inviteCode) // one-time per account + const inviteCode = process.env.SHIELD_SWAP_INVITE_CODE + if (!inviteCode) throw new Error('Set SHIELD_SWAP_INVITE_CODE for this account') + await client.api.redeemReferralCode(inviteCode) } ``` -Redemption unlocks the session immediately — the grant is recorded -server-side, no second handshake needed. +Redemption records access on the server and takes effect for the current +session. An account without access receives a 403 response from restricted +endpoints even after authenticating. Returning accounts can reuse their existing +access without redeeming another code. + +### Use an API token for a service + +A bot or server can reuse a long-lived API token instead of signing a challenge +on every startup. Create the token once from an authenticated session: -## Pools and tokens +```ts +const { token } = await client.api.createApiToken({ name: 'trading-bot' }) +// Save token in the application's secret store; its full value is shown only once. +``` -Pool discovery goes through the DEX API. Each entry carries the pool key — -an Aleo field literal that every read and swap takes — plus metadata for -both tokens. +On subsequent runs, load that saved value and pass it to the action decorator: + +```ts +const apiToken = process.env.SHIELD_SWAP_API_TOKEN +if (!apiToken) throw new Error('Set SHIELD_SWAP_API_TOKEN') + +const bot = walletClient.extend(shieldSwapActions({ api: { apiToken } })) +``` + +The token authenticates API requests. On-chain swaps still require the account +to sign and prove their transactions. Token management (`createApiToken`, +`listApiTokens`, and `revokeApiToken`) requires a session obtained by signing; +an API token cannot manage other tokens. + +## Discover existing pools and available tokens + +Use the Shield Swap API to find a pool. Each entry includes a pool key, the +identifier passed to reads and swaps, and the ids of its two tokens. Aleo +represents these ids as integers followed by `field`. Token metadata describes +the symbols, decimal places, and programs used by each asset. ```ts const { data: pools } = await client.api.getPools() const pool = pools[0] +if (!pool) throw new Error('No pools are available') pool.key // '4719…field' pool.token0 // token id, a field literal -pool.token0_info.decimals -pool.token0_info.wrapper_program // e.g. 'ethx_5a095e.aleo' +pool.token0_info?.decimals +pool.token0_info?.amm_token_program // Program used by the pool for this token. ``` -The `wrapper_program` matters: private token balances live as records inside -each token's wrapper program, so the swap and mint calls need to know which -program to look in. +`amm_token_program` identifies the token program used by the pool. Wrapped +assets also name an `underlying_program`, which holds the account's spendable +records. Token metadata can be absent from a pool response; fetch the token +list to inspect the available symbols and ids: -On-chain state comes in two parts. [`getPool`](/api/shield-swap/getPool) -returns static configuration — token pair, fee tier, decimal scales — and -[`getSlot`](/api/shield-swap/getSlot) returns live trading state: the -current sqrt price, active tick, and in-range liquidity. +```ts +const tokens = await client.listTokens() +console.table(tokens.map((token) => ({ + symbol: token.symbol, id: token.id, decimals: token.decimals, +}))) +``` + +After choosing a pool, read its current state from the node. +[`getPool`](/api/shield-swap/getPool) returns its configuration, including the +token pair and fee tier. [`getSlot`](/api/shield-swap/getSlot) returns the price +and liquidity state used by trades: ```ts const config = await client.getPool({ poolKey: pool.key }) const slot = await client.getSlot({ poolKey: pool.key }) ``` -## Program imports - -`shield_swap` calls token programs through a dynamic dispatch interface, so -the prover cannot discover them by static analysis. Every write takes an -`imports` map of program id to program source for the tokens involved. Fetch -the sources once and reuse them across calls: +## Quote, then swap -```ts -import { getProgram } from '@provablehq/veil-core' +A swap exchanges one token for another using funds in a liquidity pool. This +example spends 1.5 USDCx to receive ETH. The amount of ETH depends on the pool's +price and available liquidity when the transaction executes. -const imports = { - [pool.token0_info.wrapper_program]: await getProgram(walletClient, { programId: pool.token0_info.wrapper_program }), - [pool.token1_info.wrapper_program]: await getProgram(walletClient, { programId: pool.token1_info.wrapper_program }), -} -``` +Get a quote first to inspect the estimated return and set the lowest acceptable +output. The [Shield Swap API](#the-shield-swap-api) finds a path between the +tokens and estimates what the trade will receive. Requesting a quote reads data; +it does not sign a transaction, spend tokens, or reserve the pool's liquidity. -## Quote, then swap +### Request and inspect a quote -`client.quote` resolves a 1–3-hop route and trusts the DEX API's output estimate. -It converts the estimate to raw base units without floating-point rounding and -calculates a minimum output. No pool or tick reads occur while quoting. +Use the authenticated client from the API setup above. `amountIn: '1.5'` means +1.5 USDCx. `slippageBps: 50` allows an output up to 0.5% below the estimate, +subject to integer rounding. A basis point is one hundredth of a percent. +This tolerance determines the minimum output enforced by the swap contract: ```ts +import { formatUnits } from '@provablehq/shield-swap-sdk' + const quote = await client.quote({ from: 'USDCx', to: 'ETH', - amountIn: '1.5', // token units; bigint inputs use raw base units + amountIn: '1.5', slippageBps: 50, }) +console.log('Estimated ETH:', formatUnits(quote.expectedOut, quote.to.decimals)) +console.log('Minimum ETH:', formatUnits(quote.minOut, quote.to.decimals)) ``` -The quote expires after 60 seconds. `swap({ quote })` checks its route pools on -chain and submits exactly `quote.minOut`. Missing estimates and zero floors -reject; request a new quote when one expires. Quotes do not reserve liquidity. +The displayed amounts are decimal token amounts. Inside the returned quote, +amounts use `bigint` counts of the token's smallest unit, called a **base unit**. +For a token with six decimal places, 1.5 tokens equals 1,500,000 base units. +`formatUnits` converts that integer back to a display value using the token's +`decimals` metadata. + +For `amountIn`, a decimal string such as `'1.5'` is usually the clearest choice. +A `bigint` input means base units: `1_500_000n` means 1.5 tokens only when that +token has six decimal places. The SDK rejects JavaScript number inputs and +excess decimal precision instead of silently rounding them. + +The quote also includes its **route**, the sequence of pools used to exchange +the input token for the output token. Each pool crossed is called a **hop**: + +| Route | What happens | +| --- | --- | +| One hop | One pool exchanges the input token directly for the output token. | +| Two hops | The first pool exchanges the input for an intermediate token; the second exchanges that token for the output. | +| Three hops | Three pools perform the exchange through two intermediate tokens. | + +`quote.hops` lists those pools in order. `client.swap({ quote })` chooses the +single-pool or multi-pool execution automatically. The minimum output applies +to the final token received. Intermediate tokens do not require separate claims. + +The estimate comes from the API's indexed data; quoting does not read live pool +state from an Aleo node. Prices can change before execution. Each quote expires +60 seconds after the request starts, and a missing estimate or a minimum output +that rounds to zero causes quoting to fail. -[`swap`](/api/shield-swap/swap) submits the request — phase one. On the -local-signer path the client auto-selects an unspent record covering -`amountIn` from the token's wrapper program and derives the single-use claim -identity from the account's view key. It returns a `SwapHandle`, a plain -serializable object that is the key to claiming the output. Persist it if -there is any chance the process dies before the claim. +### Submit the quoted trade + +After reviewing the estimate and minimum, pass the quote to `swap` without +changing its fields. This call signs, proves, and submits a transaction that +spends the input token. Request a fresh quote if the previous one has expired: ```ts const handle = await client.swap({ quote }) -// Wallet accounts additionally pass tokenRecord. +console.log('Swap transaction:', handle.transactionId) ``` -A wallet never exposes its records, so the wallet path drops -`tokenInProgram` and passes `tokenRecord` as a `record` `InputRequest` -instead — the wallet resolves it against its own records and fills the -blinding slots itself. The handle then comes back without `swapId` or -`blindedAddress`; the wallet-account section of -[`swap`](/api/shield-swap/swap) shows the exact shape. +Before submission, the SDK checks the quoted pools on chain, verifies that the +quote matches the client's network, and preserves `quote.minOut` exactly. +If the contract cannot satisfy that minimum at execution, the transaction is +rejected. + +With the local signer configured in this guide, the client selects an unspent +private record holding at least 1.5 USDCx. A record is an individually spendable +token balance; several smaller records do not automatically fund this call. +Use [record management](./record-inventory.md#join-only-enough-for-a-spend) to +join them first if needed, then obtain a fresh quote. + +The returned **handle** holds the swap transaction id and the information needed +to claim its output. Retain it until the trade is finished. A service that must +recover after a restart should configure the [persistent identity store](#configure-a-shared-client) +before submitting swaps so handles are saved with their claim identities. + +Connected wallets additionally require an explicit `tokenRecord`, supplied as +a wallet record request or granted plaintext. Their handles can require fields +to be recovered from the confirmed transaction before claiming; the +[`swap` reference](/api/shield-swap/swap) documents that wallet flow. + +Shield Swap uses two transactions so it can calculate the trade against current +pool state first, then prove and pay the resulting amount privately. The swap +stores the result for a single-use blinded address, an address derived for +this trade. A separate claim proves control of that address and collects the +proceeds as private records. + +## Finalize a swap + +Finish the trade by claiming its confirmed output. Until the claim succeeds, +the proceeds remain in the contract's `swap_outputs` mapping, an on-chain table +indexed by swap id. They are not yet records that the account can spend. -## Claim the output +### Wait for the result, then claim it -Claiming — phase two — reads the chain-computed output from the -`swap_outputs` mapping and collects it as private records. -[`claimSwapOutput`](/api/shield-swap/claimSwapOutput) throws -`SwapOutputNotFinalizedError` when the request transaction has not finalized -yet; retry after a few blocks. The same error after a successful claim means -the output was already collected — claiming consumes the on-chain entry. +Continue with the `handle` and `quote` from the previous section. +`waitForSwapOutput` checks transaction confirmation and waits until the node +can return the stored result. It only reads the chain. Once that result is +available, `claimSwapOutput` submits the second transaction: ```ts -import { SwapOutputNotFinalizedError } from '@provablehq/shield-swap-sdk' - -let claimed -for (let i = 0; i < 20; i++) { - try { - claimed = await client.claimSwapOutput({ handle, imports }) - break - } catch (err) { - if (!(err instanceof SwapOutputNotFinalizedError)) throw err - await new Promise((r) => setTimeout(r, 3_000)) +await client.waitForSwapOutput({ + handle, + timeout: 600_000, + pollingInterval: 3_000, +}) + +const claimed = await client.claimSwapOutput({ handle }) +console.log('Claim transaction:', claimed.transactionId) +console.log('ETH received:', formatUnits(claimed.amountOut, quote.to.decimals)) +console.log('USDCx refunded:', formatUnits(claimed.amountRemaining, quote.from.decimals)) +``` + +This waits up to ten minutes for the result, polling every three seconds. +The claim reads the actual amounts from chain, proves ownership of the blinded +address, and creates the private output records. With a local signer, it waits +for the claim transaction to confirm before returning. + +`amountOut` is the output token received. `amountRemaining` is the unspent input +returned alongside it when the trade stops at its price limit. For example, a +trade offering 1.5 USDCx that spends 1.2 USDCx refunds 0.3 USDCx in the same claim. +A fully filled trade returns `0n` for `amountRemaining` and creates no refund +record. If a token uses an AMM wrapper, claiming returns records of the underlying +asset. + +To inspect the stored result without waiting or claiming, call +[`client.getSwapOutput({ swapId, program })`](/api/shield-swap/getSwapOutput). +It returns the output and refund amounts, or `null` when the entry is absent. + +### Recover an unfinished claim + +A missing result can mean the swap has not finalized yet, or that its output +was already claimed. Use the transaction history and saved handle to distinguish +those cases: + +| Outcome | Next action | +| --- | --- | +| `waitForSwapOutput` times out | Keep the handle and check the original transaction. A timeout does not prove failure; do not submit a replacement swap. | +| `claimSwapOutput` throws `SwapOutputNotFinalizedError` | Check whether the swap is still pending or a claim already succeeded. A successful claim removes the mapping entry. | +| Claim submission or confirmation fails | Resolve that claim's transaction status before attempting another claim. Retain the handle and recovery stores. | + +The [recovery walkthrough](#selection-failures-and-recovery) shows how to check +the journal and find outstanding claims after a restart. + +For a connected wallet, fill any missing `handle.swapId` and +`handle.blindedAddress` before claiming. The confirmed swap transaction exposes +the swap id; the stored output's `recipient` identifies the blinded address. +The wallet derives the claim proof from that address without exposing the +blinding factor to the application. See +[`claimSwapOutput`](/api/shield-swap/claimSwapOutput) for the wallet-specific +fields and confirmation behavior. + +## Concurrent swaps + +This walkthrough prepares six private USDCx records, submits six swaps for ETH, +and claims their outputs. A record holds an individually spendable amount of a +token. Spending it consumes that record, so each simultaneous swap needs a +different input even when the account has enough total USDCx. + +Each swap also uses a blinded identity: a temporary address derived from the +account's key that receives the swap's proceeds. A separate claim transaction +collects those proceeds as private records. The setup below saves both the +record reservations and the identity information needed to finish claims after +a restart. + +### Configure a shared client + +This Node example uses a funded testnet account with Shield Swap access. Install +`@provablehq/shield-swap-cli` alongside the SDK packages to use its optional +SQLite storage adapter; it requires Node 22.13 or newer. The SDK itself does not +require SQLite. For a different storage backend, supply a +[`RecordInventoryStore`](./record-inventory.md#share-a-store-and-retain-it-across-restarts). +Run only one trader process for the account with the bundled identity store. +SQLite can coordinate record reservations across processes, but the identity +store allocates its counters within one process. Two processes opening the same +`blinded.json` can allocate the same identity. + +For several clients in one process, pass the same `store` and `identities` +objects to each client and use the same `chainId`. Several processes require +separate coordination of identity allocation or distinct explicit identities; +sharing the two file paths alone is insufficient. + +```ts +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { shieldSwapActions, parseUnits } from '@provablehq/shield-swap-sdk' +import { swapFileStore } from '@provablehq/shield-swap-sdk/node' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' + +const privateKey = process.env.ALEO_PRIVATE_KEY +if (!privateKey) throw new Error('Set ALEO_PRIVATE_KEY') +const aleo = await loadNetwork('testnet') +const address = aleo.privateKeyToAccount(privateKey).address +const directory = `.veil/testnet/${address}` +const store = await sqliteRecordInventoryStore(`${directory}/inventory.sqlite`) +const identities = swapFileStore(`${directory}/blinded.json`) +const { walletClient } = aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + provingMode: 'delegated', useFeeMaster: true, confirmationTimeout: 600_000, + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, +}) +const client = walletClient.extend(shieldSwapActions({ + api: {}, blindedIdentities: identities, +})) +await client.authenticateShieldSwap() +``` + +`recordManagement.enabled` opts into reservations and recovery; attaching +`shieldSwapActions()` alone leaves them disabled. `store` records spent inputs +and prepared transactions. `identities` stores blinded addresses and swap handles +for claims. Keep both files private and reuse them after restarting. This setup +uses FeeMaster for network fees; managed private-fee selection is described in +the [record management guide](./record-inventory.md#private-fees). + +### Prepare one record per trade + +Reconcile earlier work before planning. Six swaps of 0.1 USDCx need at least +six available records holding 0.1 USDCx each. A single 0.6 USDCx record cannot +fund all six at once. Preparing a balanced inventory can join and split the +account's entire available USDCx balance; reserved records are excluded. + +```ts +const journal = await client.reconcileRecordInventory() +if (journal.some((entry) => !['confirmed', 'rejected', 'cancelled'].includes(entry.status))) { + throw new Error('Resolve pending transactions before starting another batch') +} +const pending = await client.getUnclaimedSwaps() +if (pending.swaps.length || pending.unresolvable.length) { + throw new Error('Recover previous swaps before starting another batch') +} +const token = await client.tokenData('USDCx') +const plan = await client.planInventory({ + token: token.id, + target: { + records: 6, minRecordAmount: parseUnits('0.1', token.decimals), + distribution: 'balanced', toleranceBps: 0, + }, + maxTransactions: 24, +}) +console.table(plan.steps) // Inspect the joins and splits before execution. +``` + +Planning makes no reservation and submits nothing. After reviewing `plan.steps`, +execute that same plan with `client.rebalanceRecordInventory({ plan })`. +`client.rebalanceInventory({ token, target })` instead creates a fresh plan at +call time. If preparation is interrupted, stop before submitting any swaps. + +### Submit swaps and claim their outputs + +The following calls spend funds. A new quote is fetched after maintenance so +the time spent joining and splitting does not consume its 60-second lifetime. +All six swaps use the same terms and independently select their input records. +A successful `swap` call returns a **handle** containing the transaction and +claim information. `waitForSwapOutput` waits for the on-chain result; +`claimSwapOutput` submits the transaction that collects it. + +```ts +const maintenance = await client.rebalanceRecordInventory({ + plan, timeoutMs: 600_000, pollIntervalMs: 3_000, +}) +if (maintenance.status !== 'complete') { + throw new Error(`Inventory preparation interrupted: ${maintenance.reason}`) +} + +// Fetch a fresh quote after the maintenance transactions have finished. +const quote = await client.quote({ from: 'USDCx', to: 'ETH', amountIn: '0.1', slippageBps: 50 }) +const swaps = await Promise.allSettled( + Array.from({ length: 6 }, () => client.swap({ quote })), +) +const handles = swaps.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + +// Attempt every successful swap's claim even when another trade failed. +const claims = await Promise.allSettled(handles.map(async (handle) => { + await client.waitForSwapOutput({ handle, timeout: 600_000, pollingInterval: 3_000 }) + return client.claimSwapOutput({ handle }) +})) +console.log('Swap results:', swaps.map((result) => result.status)) +console.table(claims.map((result, index) => ({ + swap: handles[index]!.transactionId, + status: result.status, + amountOut: result.status === 'fulfilled' ? result.value.amountOut.toString() : undefined, + refund: result.status === 'fulfilled' ? result.value.amountRemaining.toString() : undefined, +}))) +// Retain both stores after failures. Close SQLite after all calls have settled. +store.close() +``` + +### Selection, failures, and recovery + +The six calls above can finish with different outcomes. Follow the input +selection first, then inspect each swap and claim before starting another batch. + +#### 1. Follow two swaps competing for an input + +Consider two swaps selling 0.1 USDCx each, with two available records holding +0.1 USDCx apiece. Both calls can read the same scanner result before either has +reserved a record. A reservation marks a record as in use in the shared store, +so another participating call cannot spend it. + +One possible ordering is: + +```text +Swap A Shared record store Swap B + | | | + |--- reserve record 1 --------------->| | + |<-- acquired -----------------------|<--- reserve record 1 --------| + | |---- already reserved ------>| + | |<--- reserve record 2 --------| + | |---- acquired -------------->| + | | | + +--- prove and submit | prove and submit ---+ +``` + +This selection happens automatically for local `swap` and `swapMultiHop` calls +when record management is enabled and `tokenRecord` is omitted: + +1. Scan once for unreserved records large enough to cover the input amount, + ordered from smallest to largest. +2. Try to reserve the first candidate. If another call acquired it first, try + the next candidate. Each record nonce, which identifies a record, is tried + at most once. +3. Prove and submit after acquiring an input. If every candidate is taken, fail + before proving. The call does not wait for a deposit or join smaller records. + +Choosing record 2 leaves the trade unchanged: the input amount, minimum output, +blinded identity, and swap nonce remain fixed. Before trying another record, +the client checks that the quote has not expired and the account and network +still match. Proving, submission, and confirmation errors do not restart this +selection loop. + +An explicit `tokenRecord` fixes the input, so a reservation conflict fails that +call. Wallet accounts require a pinned record supplied by the caller; automatic +reselection applies to local signers. + +#### 2. Inspect swaps and claims separately + +Each call is a separate transaction. A quote reserves no pool liquidity, so one +trade can change the price seen by the next. Each trade still enforces its own +minimum output. If one fails, other swaps in the batch can succeed. + +For example, a six-swap batch could leave this work: + +| Result | Next action | +| --- | --- | +| Swaps 1 through 4 and their claims succeed | Record the received amounts. These trades are finished. | +| Swap 5 fails before proving because no input is available | Inspect the inventory before planning another trade. | +| Swap 6 succeeds, but waiting for its output times out | Retain its handle and recover that swap. Do not submit swap 6 again. | + +`Promise.allSettled` retains each result even when another call fails. Inspect +both arrays from the submission example; `claims[index]` corresponds to +`handles[index]`, not necessarily `swaps[index]`, because failed swaps have no +returned handle: + +```ts +for (const [index, result] of swaps.entries()) { + if (result.status === 'rejected') { + console.error(`Swap ${index + 1} needs inspection:`, result.reason) } } +for (const [index, result] of claims.entries()) { + if (result.status === 'rejected') { + console.error('Unfinished claim:', handles[index]!.transactionId, result.reason) + } +} +``` + +A successful claim can return both ETH and unspent USDCx. `amountOut` is the ETH +received; `amountRemaining` is the USDCx refund, each in its token's base units. +For example, if a 0.1 USDCx trade spends only 0.08 USDCx at the price limit, the +same claim collects its ETH proceeds and refunds the remaining 0.02 USDCx. + +#### 3. Recover the existing batch after a restart + +A timeout means the client stopped waiting; the transaction may still reach the +chain. Keep `inventory.sqlite` and `blinded.json`. Repeat +[Configure a shared client](#configure-a-shared-client) with the same account, +network, and paths, then run the recovery code below **instead of submitting +another batch**. Stop other traders for that account while recovering. + +First, check the saved transactions against the chain. The journal records both +swaps and claims, so an uncertain claim must also be resolved before claiming +again: + +```ts +const recoveryJournal = await client.reconcileRecordInventory() +const unresolved = recoveryJournal.filter((entry) => + !['confirmed', 'rejected', 'cancelled'].includes(entry.status), +) +console.table(unresolved.map((entry) => ({ + transaction: entry.transactionId, status: entry.status, +}))) +if (unresolved.length) { + throw new Error('Resolve these transactions before retrying claims or swaps') +} +``` + +Reconciliation does not submit anything by default. If a saved transaction needs +to be sent again, follow [Recover interrupted work](./record-inventory.md#recover-interrupted-work) +to rebroadcast that exact transaction. A missing confirmation does not release +its records. + +Once transaction outcomes are known, find outputs that still need claiming. +`getUnclaimedSwaps()` checks the identities in `blinded.json` against the chain +and returns saved handles for outputs that remain available: + +```ts +const unclaimed = await client.getUnclaimedSwaps() +if (unclaimed.unresolvable.length || unclaimed.swaps.some((swap) => !swap.handle)) { + throw new Error('Restore missing swap information before automatic recovery') +} -console.log(`received ${claimed!.amountOut}, refunded ${claimed!.amountRemaining}`) +// These calls collect existing outputs; they do not submit replacement swaps. +const recoveredClaims = await Promise.allSettled(unclaimed.swaps.map(async (swap) => { + await client.waitForSwapOutput({ handle: swap.handle!, timeout: 600_000 }) + return client.claimSwapOutput({ handle: swap.handle! }) +})) +for (const [index, result] of recoveredClaims.entries()) { + if (result.status === 'rejected') { + console.error('Claim still needs recovery:', unclaimed.swaps[index]!.swapId, result.reason) + } +} +store.close() // Close only after all recovery calls have settled. ``` -Claims are mandatory: a swap's tokens are not the trader's until claimed. -`amountRemaining` is non-zero when the swap partially filled at the price -limit — the unspent input comes back in the same claim. To inspect the -chain-computed result without claiming, read it directly with -[`getSwapOutput`](/api/shield-swap/getSwapOutput). - -On the wallet path the handle needs `swapId` and `blindedAddress` set before -claiming — recover them from the confirmed request transaction (the swap id -is the transition's first public output; the blinded address is the -`recipient` of the chain's `swap_outputs` entry, read with -[`getSwapOutput`](/api/shield-swap/getSwapOutput)). The wallet re-derives the -blinding factor from the blinded address, so the dApp never holds it. See -[`claimSwapOutput`](/api/shield-swap/claimSwapOutput) for the full recovery -flow. +An identity without saved swap information needs inspection of the original +transaction; the lookup cannot reconstruct every lost response. Keep both stores +until all transactions and claims are accounted for. Resolve failed claim +attempts before submitting another batch. + +The runnable [concurrent-swaps.ts](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) +adds a preview mode, a pending-batch marker, and a lock file that prevents +overlapping runs against its directory. Its +[README](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/README.md) +covers commands and recovery. The CLI's `swap-concurrent` command allows only +one trade per input token in a batch; use the SDK example for several simultaneous +USDCx sales. See [record management](./record-inventory.md) for storage options, +fee limits, wallet requirements, and join helpers. ## Liquidity @@ -261,16 +664,28 @@ a local key auto-selects records, a wallet supplies them as `record` ```ts const slot = await client.getSlot({ poolKey: pool.key }) +if (!slot) throw new Error('Pool state is unavailable') + +// Load sources for both pool tokens and any underlying assets used by the mint. +const poolTokens = await Promise.all([ + client.tokenData(pool.token0), client.tokenData(pool.token1), +]) +if (poolTokens.some((token) => !token.ammTokenProgram)) { + throw new Error('Token program metadata is unavailable') +} +const tokenPrograms = poolTokens + .flatMap((token) => [token.ammTokenProgram, token.underlyingProgram]) + .filter((program): program is string => program !== undefined) const { positionTokenId } = await client.mint({ poolKey: pool.key, + recipient: walletClient.account!.address, + withdrawal: walletClient.account!.address, tickLower: slot.tick - slot.tick_spacing * 10, tickUpper: slot.tick + slot.tick_spacing * 10, amount0Desired: 10n ** 18n, amount1Desired: 2_000_000n, - token0Program: pool.token0_info.wrapper_program, - token1Program: pool.token1_info.wrapper_program, - imports, + imports: await client.resolveDexImports({ tokenPrograms }), }) ``` diff --git a/site/docs/guides/working-with-records.md b/site/docs/guides/working-with-records.md index 4bd8091b..18748447 100644 --- a/site/docs/guides/working-with-records.md +++ b/site/docs/guides/working-with-records.md @@ -17,6 +17,10 @@ Veil hides that scan behind [`requestRecords`](/api/wallet/requestRecords). Which mechanism actually performs the scan depends on where the account's signing key lives. +For joins, splits, and shared reservations during spending, see +[record management](./record-inventory.md). It covers preparing inventory and +recovering interrupted writes using the records returned by a scanner or wallet. + ## Records from a connected wallet An RPC account — one built with `fromWalletAdapter` or `useVeilWallet` — diff --git a/site/docs/packages/core.md b/site/docs/packages/core.md index 2b56cd28..b6f9ac69 100644 --- a/site/docs/packages/core.md +++ b/site/docs/packages/core.md @@ -44,6 +44,20 @@ See the [Clients](/clients/public-client) pages for the full action surface, [Guides](/guides/reading-chain-state) for common flows, and [API](/api/types) for the type and transport reference. +## Record management + +Wallet clients include inventory reads, joins/splits, planning, and reconciliation. +Set `recordManagement: { enabled: true, store, chainId }` when constructing the +client to reserve inputs during writes. `.extend(recordActions())` only adds +methods; it does not enable coordination. + +Participating clients share one store and chain identity. The default store is +in memory; use a durable `RecordInventoryStore` for restart recovery. Core keeps +storage and signing behind interfaces and imports no database or Node runtime. +See [record management](/guides/record-inventory) for configuration defaults, +client examples, private fees, and recovery, or +[concurrent swaps](/guides/shield-swap#concurrent-swaps) for trading setup. + ## Subpaths - **`@provablehq/veil-core/agent`** — framework-agnostic LLM tool bindings: `createAgentTools`, `aleoAgentTools`, `aleoAgentToolSchemas`, and the `AgentTool` / `AgentToolSchema` types. diff --git a/site/docs/packages/provable-sdk.md b/site/docs/packages/provable-sdk.md index 8f04ea30..648a0f2f 100644 --- a/site/docs/packages/provable-sdk.md +++ b/site/docs/packages/provable-sdk.md @@ -56,3 +56,31 @@ See the [`/api/provable-sdk`](/api/provable-sdk/loadNetwork) pages for every method's parameters and defaults, and [Executing transactions](/guides/executing-transactions) for the proving config in context. + +## Record management + +`createAleoClient` accepts `recordManagement` and returns a wallet client with +inventory methods already attached. Configure its scanner and shared store +when constructing the client: + +```ts +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import type { RecordInventoryStore } from '@provablehq/veil-core' + +async function createManagedClient(privateKey: string, store: RecordInventoryStore) { + const aleo = await loadNetwork('testnet') + return aleo.createAleoClient({ + privateKey, + records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, + }).walletClient +} +// Call client.getRecordInventory(), client.planRecordInventory(), and +// client.rebalanceRecordInventory() on the returned client. +``` + +Coordination defaults to disabled. Share the store across clients, and use +durable storage when operations must survive a restart. Local and delegated +proving both persist prepared transactions before managed submission. See +[record management](/guides/record-inventory) for a complete setup and +[concurrent swaps](/guides/shield-swap#concurrent-swaps) for the trading flow. diff --git a/site/docs/packages/shield-swap-cli.md b/site/docs/packages/shield-swap-cli.md index 8d9b49b9..45a9083b 100644 --- a/site/docs/packages/shield-swap-cli.md +++ b/site/docs/packages/shield-swap-cli.md @@ -25,3 +25,51 @@ follow the runbook's execution and persistence requirements. See the [CLI reference and examples](https://github.com/ProvableHQ/veil/blob/main/packages/shield-swap-cli/README.md) for commands and configuration. For an application that embeds trading, use the [Shield Swap SDK](./shield-swap) instead. + +## Record inventory + +Private balances consist of individually spendable records. A trade needs a +record large enough for its input amount; concurrent trades need separate +records. Use `shield-swap inventory` to inspect those records and plan joins or +splits before trading. It supports native credits, ARC20, ARC22, and compatible +custom assets, and requires Node 22.13+ for its SQLite journal. SDK packages +retain their existing runtime support. + +Inventory commands use the saved Shield Swap account or a key supplied through +the environment. They do not require DEX authentication. Start by inspecting +the account's credits and previewing a plan for four records: + +```sh +shield-swap inventory inspect --asset credits.aleo +shield-swap inventory plan --asset credits.aleo --records 4 +``` + +To submit the joins and splits, run `rebalance` with `--execute`. Without that +flag, it prints a plan without signing: + +```sh +shield-swap inventory rebalance --asset credits.aleo --records 4 --execute +``` + +For ongoing maintenance, define target counts and budgets in an `inventory.json` +policy file, then run: + +```sh +shield-swap inventory run --config inventory.json --execute +shield-swap inventory status --config inventory.json +``` + +`run` also requires `--execute` to submit transactions. The loop runs in the +foreground; use a service manager for background operation. `--json` returns +one object for individual commands. A continuous `run --execute --json` prints +its summary on shutdown. + +To share reservations between maintenance and participating trading commands, +set `VEIL_INVENTORY_DB` to the same SQLite journal path and select the same +network. Trading coordination remains off unless that variable is set. Keep +the journal across restarts so pending transactions can be reconciled. + +See the [configuration and recovery reference](https://github.com/ProvableHQ/veil/blob/main/packages/shield-swap-cli/INVENTORY.md) +for a complete policy file, account settings, and interruption handling. For +client configuration and SDK examples, see the +[record management guide](../guides/record-inventory.md). diff --git a/site/docs/packages/shield-swap.md b/site/docs/packages/shield-swap.md index 850c2a64..4328e50a 100644 --- a/site/docs/packages/shield-swap.md +++ b/site/docs/packages/shield-swap.md @@ -106,11 +106,24 @@ discovery, quoting, the `imports` map — and the wallet-signer variants. `ApiClient.createApiToken` / `listApiTokens` / `revokeApiToken` for long-lived `ss_…` keys passed as `api: { apiToken }`. Most API endpoints beyond discovery are bearer-gated — - see the [guide](/guides/shield-swap#authenticating-with-the-dex-api). + see the [guide](/guides/shield-swap#the-shield-swap-api). - **Wallet grants** — `SHIELD_SWAP_ALGORITHM_GRANTS`, `shieldSwapAlgorithmGrants` — the connect-time algorithm allowlist a wallet-signer setup passes so the wallet may derive blinded identities. +## Concurrent swaps and record management + +Enable `recordManagement` when creating the wallet client, then attach +`shieldSwapActions()`. Local swaps can select different records from shared +inventory when several trades sell the same token. A separate persistent blinded +identity store retains the handles needed to claim their outputs. + +The [concurrent swaps walkthrough](/guides/shield-swap#concurrent-swaps) covers +client configuration, six-record preparation, submission, claims, and partial +failures. The [record management guide](/guides/record-inventory) documents +storage, defaults, fee limits, helper programs, and recovery. Coordination is +off by default and does not start automatic maintenance. + ## Subpaths - **`@provablehq/shield-swap-sdk/agent`** — `createShieldSwapAgentTools({ client, api, includeWrites? })` and `shieldSwapAgentToolSchemas()`. Read tools are included by default; money-moving write tools are opt-in via `includeWrites`. diff --git a/site/sidebars.ts b/site/sidebars.ts index 90ab450b..853fff4c 100644 --- a/site/sidebars.ts +++ b/site/sidebars.ts @@ -25,6 +25,7 @@ const sidebars: SidebarsConfig = { 'guides/reading-chain-state', 'guides/executing-transactions', 'guides/working-with-records', + 'guides/record-inventory', 'guides/transaction-lifecycle', 'guides/contract-instances', 'guides/devnode', diff --git a/tsconfig.json b/tsconfig.json index 256a2262..18883866 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "isolatedModules": true, "noUncheckedIndexedAccess": true, "paths": { + "@provablehq/shield-swap-cli/storage": ["./packages/shield-swap-cli/src/storage.ts"], "@provablehq/veil-core": ["./packages/core/src/index.ts"], "@provablehq/veil-core/agent": ["./packages/core/src/agent/index.ts"], "@provablehq/veil-core/mcp": ["./packages/core/src/mcp/index.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 6e7e73c8..48408ad1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ }, resolve: { alias: { + '@provablehq/shield-swap-cli/storage': path.resolve(__dirname, 'packages/shield-swap-cli/src/storage.ts'), '@provablehq/veil-core/agent': path.resolve(__dirname, 'packages/core/src/agent/index.ts'), '@provablehq/veil-core/mcp': path.resolve(__dirname, 'packages/core/src/mcp/index.ts'), '@provablehq/veil-core': path.resolve(__dirname, 'packages/core/src/index.ts'),