From 71ed97b00768e92b045645633954bc401dc909ee Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 1 Oct 2026 14:35:57 -0400 Subject: [PATCH 01/32] [Feat] Add record inventory actions and background CLI --- .changeset/config.json | 3 +- .changeset/record-inventory.md | 9 + .gitignore | 3 + README.md | 8 + apps/loyalty-dapp/src/lib/aleo.ts | 16 ++ examples/e2e-demo.ts | 26 ++ package.json | 3 +- packages/cli/README.md | 125 +++++++++ packages/cli/package.json | 39 +++ packages/cli/src/config.ts | 103 +++++++ packages/cli/src/index.ts | 100 +++++++ packages/cli/src/runner.ts | 67 +++++ packages/cli/src/storage.ts | 77 ++++++ packages/cli/test/runner.test.ts | 47 ++++ packages/cli/test/storage.test.ts | 60 +++++ packages/cli/tsconfig.json | 1 + packages/cli/tsup.config.ts | 2 + packages/core/README.md | 9 + packages/core/src/actions/records/autoJoin.ts | 43 +++ .../src/actions/records/getRecordInventory.ts | 57 ++++ packages/core/src/actions/records/internal.ts | 78 ++++++ .../core/src/actions/records/joinRecords.ts | 34 +++ .../actions/records/planRecordInventory.ts | 28 ++ .../records/rebalanceRecordInventory.ts | 126 +++++++++ .../records/reconcileRecordInventory.ts | 48 ++++ .../core/src/actions/records/splitRecord.ts | 33 +++ .../src/actions/wallet/executeContract.ts | 15 ++ .../core/src/actions/wallet/writeContract.ts | 7 + packages/core/src/index.ts | 13 + packages/core/src/inventory/internal.ts | 68 +++++ packages/core/src/inventory/planner.ts | 98 +++++++ packages/core/src/inventory/recordActions.ts | 46 ++++ packages/core/src/inventory/spending.ts | 83 ++++++ packages/core/src/inventory/store.ts | 36 +++ packages/core/src/inventory/types.ts | 144 ++++++++++ packages/core/src/types/proving.ts | 4 + .../core/test/inventory/inventory.test.ts | 253 ++++++++++++++++++ packages/provable-sdk/README.md | 11 + packages/provable-sdk/src/index.ts | 51 +++- .../provable-sdk/test/delegatedBuild.test.ts | 20 ++ .../provable-sdk/test/scannerSync.test.ts | 42 +++ packages/shield-swap-cli/README.md | 9 + packages/shield-swap-cli/package.json | 6 +- packages/shield-swap-cli/src/session.ts | 8 +- packages/shield-swap/README.md | 9 + .../src/actions/inventory/planInventory.ts | 39 +++ .../actions/inventory/rebalanceInventory.ts | 20 ++ .../src/decorators/shieldSwapActions.ts | 12 +- packages/shield-swap/src/index.ts | 3 + packages/shield-swap/src/utils/records.ts | 4 +- .../test/actions/inventory.test.ts | 35 +++ pnpm-lock.yaml | 16 ++ site/docs/guides/record-inventory.md | 106 ++++++++ site/docs/packages/cli.md | 18 ++ site/sidebars.ts | 2 + tsconfig.json | 1 + vitest.config.ts | 1 + 57 files changed, 2317 insertions(+), 8 deletions(-) create mode 100644 .changeset/record-inventory.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/runner.ts create mode 100644 packages/cli/src/storage.ts create mode 100644 packages/cli/test/runner.test.ts create mode 100644 packages/cli/test/storage.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsup.config.ts create mode 100644 packages/core/src/actions/records/autoJoin.ts create mode 100644 packages/core/src/actions/records/getRecordInventory.ts create mode 100644 packages/core/src/actions/records/internal.ts create mode 100644 packages/core/src/actions/records/joinRecords.ts create mode 100644 packages/core/src/actions/records/planRecordInventory.ts create mode 100644 packages/core/src/actions/records/rebalanceRecordInventory.ts create mode 100644 packages/core/src/actions/records/reconcileRecordInventory.ts create mode 100644 packages/core/src/actions/records/splitRecord.ts create mode 100644 packages/core/src/inventory/internal.ts create mode 100644 packages/core/src/inventory/planner.ts create mode 100644 packages/core/src/inventory/recordActions.ts create mode 100644 packages/core/src/inventory/spending.ts create mode 100644 packages/core/src/inventory/store.ts create mode 100644 packages/core/src/inventory/types.ts create mode 100644 packages/core/test/inventory/inventory.test.ts create mode 100644 packages/provable-sdk/test/scannerSync.test.ts create mode 100644 packages/shield-swap/src/actions/inventory/planInventory.ts create mode 100644 packages/shield-swap/src/actions/inventory/rebalanceInventory.ts create mode 100644 packages/shield-swap/test/actions/inventory.test.ts create mode 100644 site/docs/guides/record-inventory.md create mode 100644 site/docs/packages/cli.md diff --git a/.changeset/config.json b/.changeset/config.json index 89ef4295..0def6e61 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,7 +13,8 @@ "@provablehq/shield-swap-sdk", "@provablehq/shield-swap-cli", "@provablehq/veil-codegen", - "@provablehq/aleo-bridge-sdk" + "@provablehq/aleo-bridge-sdk", + "@provablehq/veil-cli" ] ], "linked": [], diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md new file mode 100644 index 00000000..95e21783 --- /dev/null +++ b/.changeset/record-inventory.md @@ -0,0 +1,9 @@ +--- +"@provablehq/veil-core": minor +"@provablehq/veil-aleo-sdk": minor +"@provablehq/shield-swap-sdk": minor +"@provablehq/shield-swap-cli": minor +"@provablehq/veil-cli": minor +--- + +Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add the separate Veil inventory CLI with bounded maintenance policies and a CLI-only SQLite recovery journal. SDK packages retain their existing runtime requirements. 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..6b1f21f6 100644 --- a/README.md +++ b/README.md @@ -249,3 +249,11 @@ const tools = aleoAgentTools({ ## License MIT + +### Record inventory management + +Core provides scanner-backed join, split, and inventory-rebalance actions for +ARC20, ARC22, and native credits. Shield Swap resolves DEX tokens to their +underlying programs. The separate [`@provablehq/veil-cli`](packages/cli/README.md) +application supplies background maintenance and a CLI-only SQLite journal. +Start with the [inventory guide](site/docs/guides/record-inventory.md). diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 8b6dedc9..30c45bd7 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -12,6 +12,7 @@ import { fallback, getContract, parseProgram, + recordActions, } from '@provablehq/veil-core' import { fromWalletAdapter, type AleoWalletAdapter } from '@provablehq/veil-aleo-wallet-adapter' @@ -60,3 +61,18 @@ export async function getLoyaltyContract(walletClient?: ReturnType) { + return walletClient.extend(recordActions({ chainId: 'aleo:mainnet' })) +} diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index 4c3e2930..da8bc7bd 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -10,6 +10,8 @@ import { beforeAll, describe, it, expect } from 'vitest' import { createPublicClient, + createWalletClient, + recordActions, http, parseProgram, getContract, @@ -235,3 +237,27 @@ 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. +describe('Record inventory consumer example', () => { + 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' }), + recordProvider: { requestRecords: async () => [{ + programName: 'credits.aleo', recordName: 'credits', tag: '1field', spent: false, + recordPlaintext: `{ owner: ${account.address}.private, microcredits: 4030000u64.private, _nonce: 1group.public }`, + }] }, + }).extend(recordActions({ chainId: 'aleo:testnet' })) + const plan = await client.planRecordInventory({ + asset: { program: 'credits.aleo', standard: 'credits' }, + target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, + }) + expect(plan.steps).toHaveLength(3) + expect(plan.deduction).toBe(30_000n) + expect(plan.inputs).toEqual([{ id: '1group', amount: 4_030_000n }]) + }) +}) diff --git a/package.json b/package.json index 5d8bf68d..ff0b8535 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "changeset": "changeset", "version-packages": "changeset version && node scripts/sync-versions.mjs", "release": "pnpm build && changeset publish", - "setup:first-swap": "node scripts/setup-first-swap.mjs" + "setup:first-swap": "node scripts/setup-first-swap.mjs", + "veil": "tsx packages/cli/src/index.ts" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..da0ecb2d --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,125 @@ +# @provablehq/veil-cli + +Run scanner-backed record inventory management for native `credits.aleo`, ARC20, +and ARC22 tokens. Requires Node **22.13+** for built-in SQLite. SDK consumers do +not install this application or SQLite. + +## Start + +Install `@provablehq/veil-cli`, then supply the signing key through +`ALEO_PRIVATE_KEY` using the environment or a service manager's secret facility. +Amounts are integer token base units; credits use microcredits. + +```sh +veil inventory inspect --asset credits.aleo +veil inventory plan --asset credits.aleo --records 4 +veil inventory rebalance --asset credits.aleo --records 4 --execute +veil inventory run --config inventory.json --execute +veil 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 veil inventory ...`. + +## 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", "standard": "credits" }, + "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. 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. +- `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. + +For tokens, set `standard` to `arc20` or `arc22` and use the underlying program, +not an AMM wrapper or `token_registry.aleo`. Bound ARC22 records and compliance +records are excluded. The application reuses `createRemoteScanner` and waits for +its reported synchronization before planning. + +## 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. +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. + +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. + +Only one manager can run for an account/chain against a shared journal. Pending +trading operations take priority over maintenance. Participating trading clients +must open the same file and apply `recordActions` before `shieldSwapActions`: + +```ts +import { recordActions } from '@provablehq/veil-core' +import { sqliteRecordInventoryStore } from '@provablehq/veil-cli/storage' + +const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') +const managed = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) +// Extend managed with shieldSwapActions(...) when trading. +// Close the store when the application shuts down. +``` + +For the Shield Swap CLI, set `VEIL_INVENTORY_DB` to this same database path and +select the same network. That opt-in integration also requires Node 22.13+. +Other SDK applications can implement `RecordInventoryStore` using their own +storage without depending on this package. Unmanaged wallets, raw transaction +submission, and other database files cannot participate in these reservations. +Managed writes use public fees or FeeMaster; private fee-record selection is +not coordinated and is rejected. + +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/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..15dff71f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,39 @@ +{ + "name": "@provablehq/veil-cli", + "version": "0.11.1", + "description": "Record inventory management for Aleo.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ProvableHQ/veil.git", + "directory": "packages/cli" + }, + "type": "module", + "bin": { + "veil": "./dist/index.js" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22.13" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@provablehq/veil-core": "workspace:^", + "@provablehq/veil-aleo-sdk": "workspace:^" + }, + "exports": { + "./storage": { + "types": "./dist/storage.d.ts", + "import": "./dist/storage.js" + } + } +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 00000000..902e2502 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,103 @@ +import type { InventoryTarget, RecordAsset } from '@provablehq/veil-core' + +/** + * Defines one asset's maintenance policy in base units. + * @property asset Native credits, ARC20 or ARC22 program. + * @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. + * @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 +} + +/** + * 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 + 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') + 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') + 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) || !['credits', 'arc20', 'arc22'].includes(asset.standard)) throw new Error('Invalid inventory asset') + if ((asset.standard === 'credits') !== (asset.program === 'credits.aleo')) throw new Error('credits.aleo requires the credits standard') + 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 } + }) + 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 } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..06e199ec --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util' +import { readFile } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' +import { recordActions } from '@provablehq/veil-core' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' +import { sqliteRecordInventoryStore } from './storage.js' +import { parseInventoryConfig } from './config.js' +import { runInventoryCycle } from './runner.js' + +const HELP = `veil inventory + + --config JSON policy configuration + --asset One-off asset program (credits.aleo or an ARC token) + --standard credits, arc20 or arc22 (required for non-credits assets) + --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 + --help Show this help + +Amounts in configuration are decimal strings in token base units. +The private key is read from ALEO_PRIVATE_KEY or config.privateKeyEnv. +run stays in the foreground for a service manager; Ctrl-C preserves pending work. +Node 22.13 or newer is required.` + +const print = (value: unknown) => console.log(JSON.stringify(value, (_, item) => typeof item === 'bigint' ? item.toString() : item, 2)) + +async function main(): Promise { + const { values, positionals } = parseArgs({ allowPositionals: true, options: { + config: { type: 'string' }, asset: { type: 'string' }, standard: { type: 'string' }, records: { type: 'string' }, + network: { type: 'string' }, database: { type: 'string' }, execute: { type: 'boolean' }, help: { type: 'boolean' }, + } }) + if (values.help || !positionals.length) { console.log(HELP); return } + const [group, command] = positionals + if (group !== 'inventory' || !command || !['inspect', 'plan', 'rebalance', 'run', 'status'].includes(command) || positionals.length !== 2) throw new Error(HELP) + const raw = values.config ? JSON.parse(await readFile(values.config, 'utf8')) : {} + if (values.network) raw.network = values.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 ?? (values.asset === 'credits.aleo' ? 'credits' : undefined) }, 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] + if (!privateKey) throw new Error(`Set ${config.privateKeyEnv} to the signing account private key`) + const aleo = await loadNetwork(config.network) + const { walletClient, account } = aleo.createAleoClient({ privateKey, networkUrl: config.networkUrl, useFeeMaster: config.useFeeMaster, + records: aleo.createRemoteScanner({ waitForSync: true }) }) + const store = await sqliteRecordInventoryStore(config.database) + const client = walletClient.extend(recordActions({ store, chainId: config.chainId, maxFeeMicrocredits: config.maxFeeMicrocredits })) + const scope = JSON.stringify([config.chainId, account.address]) + let lease: string | undefined + const controller = new AbortController() + const stop = () => controller.abort() + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + try { + if (command === 'status') { + print((await store.list(scope)).map(({ transaction: _transaction, ...entry }) => entry)); return + } + if (command === 'inspect') { + for (const policy of config.policies) { + const inventory = await client.getRecordInventory({ asset: policy.asset }) + print({ asset: policy.asset, balance: inventory.balance, + available: inventory.available.map(({ id, amount }) => ({ id, amount })), + reserved: inventory.reserved.map(({ id, amount }) => ({ id, amount })) }) + } + return + } + if (command === 'plan' || !values.execute) { + for (const policy of config.policies) print(await client.planRecordInventory({ ...policy, maxTransactions: config.maxTransactions })) + return + } + 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 + do { + try { + const results = await runInventoryCycle(client, config, store, scope, controller.signal) + // Background runs emit only actionable results; stable inventory stays quiet. + if (command !== 'run' || results.some((result) => !['satisfied', 'cooldown'].includes(result.status))) print(results) + if (command !== 'run' && results.some((result) => ['interrupted', 'unavailable', 'waiting', 'budget'].includes(result.status))) process.exitCode = 1 + } catch (error) { + if (command !== 'run') throw error + console.error(error instanceof Error ? error.message : String(error)) + } + 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) + } finally { + try { if (lease) await store.update(scope, lease, { status: 'cancelled' }) } + finally { + store.close() + process.removeListener('SIGINT', stop) + process.removeListener('SIGTERM', stop) + } + } +} + +main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }) diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts new file mode 100644 index 00000000..7926d713 --- /dev/null +++ b/packages/cli/src/runner.ts @@ -0,0 +1,67 @@ +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 recordActions. + * @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 { + 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) return [{ program: '*', status: 'waiting', reason: 'Unresolved transactions retain their reservations' }] + const results: InventoryCycleResult[] = [] + let transactions = 0 + 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() + 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.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 }) + 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 } + 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/cli/src/storage.ts b/packages/cli/src/storage.ts new file mode 100644 index 00000000..c6432db9 --- /dev/null +++ b/packages/cli/src/storage.ts @@ -0,0 +1,77 @@ +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 = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) + */ +export async function sqliteRecordInventoryStore(path: string): Promise { + 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 } + 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) + 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 => { + 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(() => { + recover(entry.scope) + if (new Set(entry.records).size !== entry.records.length) throw new Error('Duplicate record inputs') + if (read(entry.scope).some((old) => old.status !== 'cancelled' && old.status !== 'rejected' && + 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/cli/test/runner.test.ts b/packages/cli/test/runner.test.ts new file mode 100644 index 00000000..f5aa40be --- /dev/null +++ b/packages/cli/test/runner.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { parseInventoryConfig } from '../src/config.js' +import { runInventoryCycle } from '../src/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('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('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({ 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({ 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/cli/test/storage.test.ts b/packages/cli/test/storage.test.ts new file mode 100644 index 00000000..64534b94 --- /dev/null +++ b/packages/cli/test/storage.test.ts @@ -0,0 +1,60 @@ +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 } 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('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 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/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..9379bff0 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": ".." }, "include": ["src"] } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 00000000..01817d80 --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'tsup' +export default defineConfig({ entry: ['src/index.ts', 'src/storage.ts'], format: ['esm'], dts: true, clean: true, sourcemap: true }) diff --git a/packages/core/README.md b/packages/core/README.md index bf8968c6..65ec44d4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -245,3 +245,12 @@ const client = createPublicClient({ }, })) ``` + +## Record inventory + +Extend a wallet client with `recordActions({ store?, chainId?, maxFeeMicrocredits? })` +for scanner-backed `joinRecords`, `splitRecord`, `autoJoin`, +`planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. Supports +ARC20, ARC22, and native credits. Core supplies a storage interface and in-memory +default; it has no SQLite or Node dependency. See the +[record inventory guide](../../site/docs/guides/record-inventory.md). diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts new file mode 100644 index 00000000..a1d9be52 --- /dev/null +++ b/packages/core/src/actions/records/autoJoin.ts @@ -0,0 +1,43 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryResult, RecordAsset } from '../../inventory/types.js' +import { 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 Client extended with recordActions. + * @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 + * const result = await autoJoin(client, { asset, minAmount: 1_000_000n }) + */ +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) { + 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 } + const plan = buildInventoryPlan({ scope: scopeOf(client), asset: params.asset, 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..b0359bf3 --- /dev/null +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -0,0 +1,57 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryRecord, RecordAsset } 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 and standard whose eligible records are requested. + */ +export type GetRecordInventoryParameters = { asset: RecordAsset } + +/** + * Reports spendable records separately from records held by other operations. + * @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 = { 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, optionally extended with recordActions. + * @param params Token program and standard 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 + * const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo', standard: 'credits' } }) + */ +export async function getRecordInventory(client: Client, params: GetRecordInventoryParameters): Promise { + 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(params.asset)], resultsPerPage: 1000, page } }), + }) + if (client.account?.type !== 'rpc' && records.length > 1000) throw new Error('Record provider ignored pagination; inventory is incomplete') + 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 + 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 + } + const available = [...seen.values()].filter((record) => !excluded.has(record.id)) + const reserved = [...seen.values()].filter((record) => excluded.has(record.id)) + return { 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..e9b333b6 --- /dev/null +++ b/packages/core/src/actions/records/internal.ts @@ -0,0 +1,78 @@ +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 } 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 intrinsic split deduction in base units, independently of network fees. @internal */ +export const splitDeduction = (asset: RecordAsset): bigint => asset.standard === 'credits' ? 10_000n : 0n +/** Selects the native amount width for record parsing and encoded inputs. @internal */ +export const amountWidth = (asset: RecordAsset): 'u64' | 'u128' => asset.standard === 'credits' ? 'u64' : 'u128' +/** Selects the native record type for the scanner filter. @internal */ +export const recordName = (asset: RecordAsset): string => asset.standard === 'credits' ? 'credits' : 'Token' +/** Returns the largest representable record amount for this asset. @internal */ +export const maxAmount = (asset: RecordAsset): bigint => (1n << (asset.standard === 'credits' ? 64n : 128n)) - 1n + +/** Verifies the deployed join/split interface and reports required field grants. @internal */ +export async function validateAsset(client: Client, asset: RecordAsset): Promise<{ recipientBound: boolean }> { + if (!asset || !/^[a-z][a-z0-9_]*\.aleo$/.test(asset.program)) throw new Error('Invalid asset program') + if (asset.standard === 'credits') { + if (asset.program !== 'credits.aleo') throw new Error('Native credits require credits.aleo') + return { recipientBound: false } + } + if (asset.standard !== 'arc20' && asset.standard !== 'arc22') throw new Error('Unsupported token standard') + const source = await getCode(client, { programId: asset.program }) + const report = checkProgramConformance(source, asset.standard) + // Inventory depends on the Token and join/split interface, not transfer views. + // Deployed stablecoins can expose these capabilities without newer read accessors. + 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 (report.programId !== asset.program || violations.length) throw new Error(`${asset.program} has an incompatible token join/split interface`) + return { recipientBound: parseProgram(source).records.find((record) => record.name === 'Token')?.fields.some((field) => field.name === 'recipient_bound') ?? false } +} + +/** Extracts an eligible owned amount or rejects incomplete wallet grants. @internal */ +export function decodeRecord(client: Client, asset: RecordAsset, record: OwnedRecordEncrypted, capabilities?: { recipientBound: boolean }): InventoryRecord | undefined { + if (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.standard === 'credits' ? 'microcredits' : 'amount' + let amount: bigint + if (plaintext) { + 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 { + 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 { + 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..57766e59 --- /dev/null +++ b/packages/core/src/actions/records/joinRecords.ts @@ -0,0 +1,34 @@ +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 { decodeRecord, maxAmount, recordInput, validateAsset } from './internal.js' + +/** + * Supplies two owned records for a native join. + * @property asset Program and supported standard. + * @property records Two distinct records from getRecordInventory. + * @property onProgress Optional awaited checkpoint callback; defaults to no reporting. + */ +export type JoinRecordsParameters = { asset: RecordAsset; records: [InventoryRecord, InventoryRecord]; onProgress?: ProvingProgressHandler } + +/** + * Joins two records through the asset's native join transition. + * Signs, proves and submits using the existing account and proving configuration. + * @param client Wallet client; recordActions enables shared reservations. + * @param params Two distinct, same-owner records from getRecordInventory. + * @returns Submitted transaction id; acceptance is asynchronous. + * @throws On incompatible records, overflow, contention or proving failure. + * @example + * const transactionId = await joinRecords(client, { asset, records: [a, b] }) + */ +export async function joinRecords(client: Client, params: JoinRecordsParameters): Promise { + const capabilities = await validateAsset(client, params.asset) + if (params.records.length !== 2) throw new Error('joinRecords requires two records') + const records = params.records.map((record) => decodeRecord(client, params.asset, record.record, capabilities)) + const [a, b] = records + if (!a || !b || a.id === b.id) throw new Error('Join requires distinct eligible records owned by this account') + if (a.amount + b.amount > maxAmount(params.asset)) throw new Error('Joined amount overflows the token width') + return writeContract(client, { program: params.asset.program, function: 'join', + inputs: [recordInput(client, params.asset, a), recordInput(client, params.asset, b)], 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..8ae9d258 --- /dev/null +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -0,0 +1,28 @@ +import type { Client } from '../../clients/createClient.js' +import type { InventoryPlan, InventoryTarget, RecordAsset } from '../../inventory/types.js' +import { 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 and supported standard. + * @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 + * const plan = await planRecordInventory(client, { asset, target: { records: 4 } }) + */ +export async function planRecordInventory(client: Client, params: PlanRecordInventoryParameters): Promise { + const inventory = await getRecordInventory(client, { asset: params.asset }) + return buildInventoryPlan({ ...params, 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..134ad88b --- /dev/null +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -0,0 +1,126 @@ +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 } from './internal.js' +import type { ProvingProgressHandler } from '../../types/proving.js' +import type { Transaction } from '../../types/transaction.js' +import type { ConfirmedTransaction } from '../../types/block.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. + */ +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. + * @param client Client extended with recordActions for reservation and durable progress. + * @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 or missing coordination, before submission. + * @example + * const result = await rebalanceRecordInventory(client, { plan, maxFeeMicrocredits: 1_000_000n }) + */ +export async function rebalanceRecordInventory(client: Client, params: RebalanceRecordInventoryParameters): Promise { + const config = management(client) + if (!config) throw new Error('Inventory execution requires recordActions') + // 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') + const result: InventoryResult = { status: 'complete', transactionIds: [], completedSteps: 0 } + const references = new Map(plan.inputs.map((input) => [input.id, input.id])) + 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 { + for (const step of plan.steps) { + check() + 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 + }) + check() + 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, maxFeeMicrocredits: limit } }) as Client + 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 as [InventoryRecord, InventoryRecord], 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 + 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 transition = (confirmed.transaction as Transaction).execution?.transitions.at(-1) + if (transition?.program !== plan.asset.program || transition.function !== step.kind) throw new Error('Confirmed transition does not match the inventory plan') + const commitments = (transition.outputs ?? []).filter((output) => output.type === 'record' || output.type === 'record_with_dynamic_id').map((output) => output.id) + 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) + return decoded ? [decoded] : [] + }) + if (outputs.length !== commitments.length) await pause() + } + 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) { + 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..ed7098b4 --- /dev/null +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -0,0 +1,48 @@ +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. + * @param client Client extended with recordActions and the original durable store. + * @param params Optional exact-transaction rebroadcast, disabled by default. + * @returns Current scoped journal. Reads the chain and optionally resubmits transactions. + * @throws On storage/network errors other than transaction-not-found (HTTP 404). + * @example + * const journal = await reconcileRecordInventory(client, { rebroadcast: true }) + */ +export async function reconcileRecordInventory(client: Client, params: ReconcileRecordInventoryParameters = {}): Promise { + const config = management(client) + if (!config) throw new Error('Reconciliation requires recordActions 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 + try { + const result = await getConfirmedTransaction(client, { id: entry.transactionId }) + if (result.status === 'accepted' || result.status === 'rejected') { + await config.store.update(scope, entry.id, { status: result.status === 'accepted' ? 'confirmed' : 'rejected', transaction: undefined }) + continue + } + throw new Error('Unexpected transaction confirmation response') + } catch (error) { + if ((error as { status?: number }).status !== 404) throw error + missing = true + } + if (missing && params.rebroadcast && entry.transaction) { + 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') + await config.store.update(scope, entry.id, { status: 'submitted' }) + } + } + 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..df645896 --- /dev/null +++ b/packages/core/src/actions/records/splitRecord.ts @@ -0,0 +1,33 @@ +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 and supported standard. + * @property record Source record from getRecordInventory. + * @property amount Positive first output in integer base units (u64 credits, u128 tokens). + * @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; recordActions enables shared reservations. + * @param params Source record and positive first output amount (u64 credits, u128 tokens). + * @returns Submitted transaction id, before confirmation. + * @throws When either output would be empty or inputs are incompatible. + * @example + * const transactionId = await splitRecord(client, { asset, record, amount: 500_000n }) + */ +export async function splitRecord(client: Client, params: SplitRecordParameters): Promise { + const capabilities = await validateAsset(client, params.asset) + const record = decodeRecord(client, params.asset, params.record.record, capabilities) + if (!record || typeof params.amount !== 'bigint' || params.amount <= 0n || + params.amount + splitDeduction(params.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, params.asset, record), `${params.amount}${amountWidth(params.asset)}`], onProgress: params.onProgress }) +} diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index 3776ac6b..5c80d306 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' @@ -22,6 +25,7 @@ import { extractTransitions } from '../../utils/extractTransitions.js' * client's record provider; callers do not supply one. * @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 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. @@ -34,6 +38,7 @@ export type ExecuteContractParameters = { privateFee?: boolean programSource?: string imports?: Record + onProgress?: ProvingProgressHandler } /** Transaction id plus per-transition outputs of the confirmed execution. */ @@ -75,6 +80,11 @@ export async function executeContract( client: Client, params: ExecuteContractParameters, ): Promise { + if (management(client)) { + const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } + return manageRecordSpend(client, snapshot, (onProgress) => + executeContract(withoutManagement(client), { ...snapshot, onProgress })) + } const account = client.account if (!account || !('sign' in account)) { throw new AccountNotFoundError() @@ -95,9 +105,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) @@ -117,6 +131,7 @@ export async function executeContract( privateFee: params.privateFee, 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..c653b45f 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' @@ -67,6 +69,11 @@ export async function writeContract( client: Client, params: WriteContractParameters, ): Promise { + if (management(client)) { + const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } + return manageRecordSpend(client, snapshot, (onProgress) => + writeContract(withoutManagement(client), { ...snapshot, onProgress })) + } const account = client.account if (!account || !('sign' in account)) { throw new AccountNotFoundError() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5555bbc..db54dcab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -263,3 +263,16 @@ 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 { 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..fcedf764 --- /dev/null +++ b/packages/core/src/inventory/internal.ts @@ -0,0 +1,68 @@ +import type { Client } from '../clients/createClient.js' +import type { OwnedRecordEncrypted, OwnedRecord } from '../types/records.js' +import { parseRecord } from '../utils/records.js' +import type { RecordActionsConfig, RecordInventoryStore } from './types.js' +import { holdsRecords } from './store.js' + +/** + * Carries the coordination context attached by recordActions. + * @property chainId Stable chain identity for reservation scopes. + * @property store Shared atomic journal. + * @property maxFeeMicrocredits Optional proved fee ceiling; absent means no limit. + * @property network Original transport network, checked against later network switches. + * @internal + */ +export type RecordManagement = Required> & { + store: RecordInventoryStore + maxFeeMicrocredits?: bigint + network: string | null | undefined +} + +/** Reads coordination settings and rejects a transport that switched networks. @internal */ +export function management(client: Client): RecordManagement | undefined { + const config = (client as Client & { recordManagement?: RecordManagement }).recordManagement + if (config && config.network !== client.transport.config.network) { + throw new Error('Record management network changed; rebuild recordActions for the new chain') + } + return config +} + +/** Derives the reservation namespace from the active chain and account address. @internal */ +export function scopeOf(client: Client): string { + const chain = management(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 extended with recordActions; returns an empty set otherwise. + * @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() + return new Set((await config.store.list(scopeOf(client))).filter(holdsRecords).flatMap((entry) => entry.records)) +} + +/** 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/planner.ts b/packages/core/src/inventory/planner.ts new file mode 100644 index 00000000..6d127e55 --- /dev/null +++ b/packages/core/src/inventory/planner.ts @@ -0,0 +1,98 @@ +import type { InventoryPlan, InventoryStep, InventoryTarget, RecordAsset } from './types.js' +import { positiveInteger } from './internal.js' +import { maxAmount, splitDeduction } from '../actions/records/internal.js' + +/** + * Computes a deterministic join/split dependency plan without network calls. + * @param params Scoped record amounts, target shape 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 +}): InventoryPlan { + const { asset } = params + 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) + 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()) + 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) + 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, + 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 + while (working.length > reduceTo) { + working.sort((a, b) => a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : a.id.localeCompare(b.id)) + const [a, b] = working + // Intermediate joins can be below the output minimum while accumulating dust. + const amount = a!.amount + b!.amount + 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', inputs: [a!.id, b!.id], outputs: [output], deduction: 0n }) + working = working.slice(2).concat(output) + } + 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 outputsNeeded = repack ? target.records - working.length + 1 : 2 + const deduction = splitDeduction(asset) + const usable = record.amount - deduction * BigInt(outputsNeeded - 1) + const amount = usable / BigInt(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, + 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..5eb5e183 --- /dev/null +++ b/packages/core/src/inventory/recordActions.ts @@ -0,0 +1,46 @@ +import type { Client } from '../clients/createClient.js' +import type { RecordActionsConfig } from './types.js' +import type { RecordManagement } from './internal.js' +import { memoryRecordInventoryStore } from './store.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 { writeContract, type WriteContractParameters } from '../actions/wallet/writeContract.js' +import { executeContract, type ExecuteContractParameters } from '../actions/wallet/executeContract.js' + +/** + * Adds record inventory actions and shared spending coordination to an account client. + * Configuration is opt-in; apply before protocol decorators such as shieldSwapActions. + * @param config Shared store, chain identity, and optional per-transaction fee limit. + * @returns An extend decorator; construction performs no network calls. + * @throws When chain identity is absent or the fee limit is negative. + * @example + * const client = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) + */ +export function recordActions(config: RecordActionsConfig = {}) { + const store = config.store ?? memoryRecordInventoryStore() + return (client: Client) => { + const chainId = config.chainId ?? client.transport.config.network + if (!chainId) throw new Error('recordActions requires chainId on a transport without a network') + if (config.maxFeeMicrocredits !== undefined && config.maxFeeMicrocredits < 0n) throw new Error('Fee limit cannot be negative') + const recordManagement: RecordManagement = { store, chainId, network: client.transport.config.network, maxFeeMicrocredits: config.maxFeeMicrocredits } + const scoped = Object.assign(Object.create(client), { recordManagement }) as Client + return { + recordManagement, + getRecordInventory: (params: GetRecordInventoryParameters) => getRecordInventory(scoped, params), + planRecordInventory: (params: PlanRecordInventoryParameters) => planRecordInventory(scoped, params), + rebalanceRecordInventory: (params: RebalanceRecordInventoryParameters) => rebalanceRecordInventory(scoped, params), + reconcileRecordInventory: (params?: ReconcileRecordInventoryParameters) => reconcileRecordInventory(scoped, params), + joinRecords: (params: JoinRecordsParameters) => joinRecords(scoped, params), + splitRecord: (params: SplitRecordParameters) => splitRecord(scoped, params), + autoJoin: (params: AutoJoinParameters) => autoJoin(scoped, params), + writeContract: (params: WriteContractParameters) => writeContract(scoped, params), + executeTransaction: (params: WriteContractParameters) => writeContract(scoped, params), + executeContract: (params: ExecuteContractParameters) => executeContract(scoped, params), + } + } +} diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts new file mode 100644 index 00000000..6951bfae --- /dev/null +++ b/packages/core/src/inventory/spending.ts @@ -0,0 +1,83 @@ +import type { Client } from '../clients/createClient.js' +import type { TransactionInput } from '../types/inputRequest.js' +import type { ProvingProgressHandler } from '../types/proving.js' +import type { Transaction } from '../types/transaction.js' +import { requestRecords } from '../actions/wallet/requestRecords.js' +import { parseRecord } from '../utils/records.js' +import { inventoryRecordId, management, scopeOf } from './internal.js' + +/** Builds an internal client view that avoids recursive reservation acquisition. @internal */ +export function withoutManagement(client: Client): Client { + return Object.assign(Object.create(client), { recordManagement: undefined }) as Client +} + +/** Reads the public base and priority fees in microcredits; rejects an unverifiable fee. @internal */ +export function transactionFee(tx: Transaction): bigint { + if (!tx.fee) return 0n + const values = tx.fee.transition.inputs?.slice(0, 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 + onProgress?: ProvingProgressHandler +}, submit: (onProgress: ProvingProgressHandler) => Promise): Promise { + const config = management(client)! + if (params.privateFee) throw new Error('Managed records require public fees or FeeMaster; private fee selection is not coordinated') + const scope = scopeOf(client) + if (client.account?.type === 'rpc' && config.maxFeeMicrocredits !== undefined) throw new Error('Wallet accounts cannot enforce a fee ceiling before submission') + 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') + 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') + const entry = await config.store.acquire({ scope, records: ids, program: params.program, + function: params.function, status: 'reserved', createdAt: Date.now(), accountType: client.account?.type === 'local' ? 'local' : 'rpc' }) + if (!entry) throw new Error('Record inventory changed: an input is already reserved or spent') + 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') { + const fee = transactionFee(event.transaction) + 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') { + 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 }) + } + await params.onProgress?.(event) + }) + } 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' }) + } + throw error + } +} diff --git a/packages/core/src/inventory/store.ts b/packages/core/src/inventory/store.ts new file mode 100644 index 00000000..cc8a852a --- /dev/null +++ b/packages/core/src/inventory/store.ts @@ -0,0 +1,36 @@ +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) => { + 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) && + old.records.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)) + if ('transaction' in patch && patch.transaction === undefined) delete entry.transaction + }, + } +} + +/** Returns whether a journal entry excludes its inputs from record selection. */ +export function holdsRecords(entry: RecordSpend): boolean { + return entry.status !== 'cancelled' && entry.status !== 'rejected' +} diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts new file mode 100644 index 00000000..950f951f --- /dev/null +++ b/packages/core/src/inventory/types.ts @@ -0,0 +1,144 @@ +import type { OwnedRecordEncrypted } from '../types/records.js' +import type { Transaction } from '../types/transaction.js' + +/** + * Describes the program whose fungible records are managed. + * @property program Deployed program id; native credits MUST use credits.aleo. + * @property standard Record and transition shape, verified against deployed code. + * @example + * const asset: RecordAsset = { program: 'credits.aleo', standard: 'credits' } + */ +export type RecordAsset = { program: string; standard: 'credits' | 'arc20' | 'arc22' } + +/** + * 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 u64 for credits or u128 for 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. + * @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. + */ +export type InventoryStep = { + kind: 'join' | 'split' + 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 Verified token program and standard. + * @property target Requested inventory shape. + * @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. + */ +export type InventoryPlan = { + scope: string + asset: RecordAsset + target: InventoryTarget + inputs: { id: string; amount: bigint }[] + steps: InventoryStep[] + deduction: bigint + maxTransactions: number +} + +/** 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. + * @property program Target transaction program. + * @property function Transition name. + * @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. + */ +export type RecordSpend = { + id: string + scope: string + records: string[] + program: string + function: string + status: RecordSpendStatus + createdAt: number + accountType: 'local' | 'rpc' + transactionId?: string + transaction?: Transaction + feeMicrocredits?: 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. + * @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 for an existing operation. + * @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 opt-in coordination for inventory and ordinary writes. + * @property store Shared reservation/journal store. Defaults to an in-memory store per decorator. + * @property chainId Stable chain identity shared by all participants. Defaults to transport network; + * custom/local chains MUST supply a distinct identity. + * @property maxFeeMicrocredits Maximum proved transaction fee, before broadcast. Optional, no limit by default. + */ +export type RecordActionsConfig = { + store?: RecordInventoryStore + chainId?: string + maxFeeMicrocredits?: bigint +} + +/** + * 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..916e6e3f 100644 --- a/packages/core/src/types/proving.ts +++ b/packages/core/src/types/proving.ts @@ -82,8 +82,12 @@ export type SimulateOptions = { * the public credits balance. Defaults to false. * @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[] diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts new file mode 100644 index 00000000..c8b885d8 --- /dev/null +++ b/packages/core/test/inventory/inventory.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createWalletClient, recordActions, memoryRecordInventoryStore, parseRecord, type OwnedRecord, type RecordAsset } 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 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) { + 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') return source(asset) + 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 === 'join' ? [value(inputs[0]) + value(inputs[1])] : + [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: [{ 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() {} } }).extend(recordActions({ store })) + 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('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' + throw new Error(method) + }) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any }).extend(recordActions({ 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])('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 private fees', 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('public fees') + expect(w.build).not.toHaveBeenCalled() + }) +}) diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index c9351951..ba678ac5 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -160,3 +160,14 @@ 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. + +## Inventory scanning and proving + +Use `createRemoteScanner({ waitForSync: true })` with core's `recordActions` for +inventory maintenance. Synchronization polling uses the existing SDK scanner; +`syncTimeoutMs` defaults to 300000 milliseconds and `waitForSync` defaults to false +for existing consumers. Both local and delegated execution honor an awaited +`transaction-prepared` callback before submission, allowing an application to +checkpoint a proved transaction. Storage is supplied by the application; SQLite +belongs only to the separate Veil CLI. See the +[inventory guide](../../site/docs/guides/record-inventory.md). diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index 4beb030a..8411a4ce 100644 --- a/packages/provable-sdk/src/index.ts +++ b/packages/provable-sdk/src/index.ts @@ -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 @@ -866,7 +870,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? false, - broadcast: true, + broadcast: !execOptions.onProgress, useFeeMaster: options.useFeeMaster ?? false, }) @@ -913,7 +917,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 +936,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 } @@ -946,6 +959,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw new ProvingError({ message: e instanceof Error ? e.message : String(e), cause: e as Error }) } + 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 +971,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 +1145,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } function createRemoteScanner(options: { + waitForSync?: boolean + syncTimeoutMs?: number url?: string consumerId?: string apiKey?: string @@ -1139,6 +1158,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 +1276,24 @@ 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) { + const deadline = Date.now() + (options.syncTimeoutMs ?? 300_000) + let reregistered = false + while (true) { + const status = await activeScanner.status() + // status() does not use the SDK's owned() auto-registration recovery. + 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) }, @@ -1627,16 +1667,23 @@ export function createDevnodeClient(options?: { imports: execOptions.programImports, }) + 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 diff --git a/packages/provable-sdk/test/delegatedBuild.test.ts b/packages/provable-sdk/test/delegatedBuild.test.ts index 5d8f0bc9..517a1bf4 100644 --- a/packages/provable-sdk/test/delegatedBuild.test.ts +++ b/packages/provable-sdk/test/delegatedBuild.test.ts @@ -11,6 +11,7 @@ describe('delegated transaction building', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('asks the prover not to broadcast and reports each proving boundary', async () => { @@ -49,6 +50,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/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/README.md b/packages/shield-swap-cli/README.md index 2612d605..0c0ded09 100644 --- a/packages/shield-swap-cli/README.md +++ b/packages/shield-swap-cli/README.md @@ -232,3 +232,12 @@ 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. + +## Share record reservations with inventory maintenance + +Set `VEIL_INVENTORY_DB` to the same journal path used by `veil inventory run`. +The session applies core's `recordActions` before Shield Swap actions so trading +and maintenance cannot reserve the same explicit record inputs. This optional +CLI integration loads `@provablehq/veil-cli/storage` and requires Node 22.13+. +Without the environment variable, the existing runtime path is unchanged. +See the [Veil CLI reference](../cli/README.md) for policy configuration and recovery. diff --git a/packages/shield-swap-cli/package.json b/packages/shield-swap-cli/package.json index 98b0ae97..62e71621 100644 --- a/packages/shield-swap-cli/package.json +++ b/packages/shield-swap-cli/package.json @@ -35,7 +35,8 @@ }, "dependencies": { "@provablehq/shield-swap-sdk": "workspace:^", - "@provablehq/veil-aleo-sdk": "workspace:^" + "@provablehq/veil-aleo-sdk": "workspace:^", + "@provablehq/veil-core": "workspace:^" }, "keywords": [ "aleo", @@ -50,5 +51,8 @@ ], "bugs": { "url": "https://github.com/ProvableHQ/veil/issues" + }, + "optionalDependencies": { + "@provablehq/veil-cli": "workspace:^" } } diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index de7733cb..dc751fb5 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -23,6 +23,7 @@ * version control (`.shield-swap/` belongs in .gitignore) and treat it like * a wallet file. */ +import { recordActions } from '@provablehq/veil-core' import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, renameSync } from 'node:fs' import { join } from 'node:path' import { @@ -260,9 +261,14 @@ export async function loadSession(options: { network?: string } = {}) { // meaningful against the chain it was checked on. Every swap through this // client reserves and records automatically. const blindedIdentities = fileBlindedIdentityStore(blindedStorePath(network)) + // Opt in to the same durable record journal as `veil inventory run`. + const inventoryPath = process.env.VEIL_INVENTORY_DB + const coordinated = inventoryPath + ? walletClient.extend(recordActions({ store: await (await import('@provablehq/veil-cli/storage')).sqliteRecordInventoryStore(inventoryPath), chainId: `aleo:${network}` })) + : walletClient // SDK proving supplies automatic freezelist witnesses for every write command; // shieldSwapActions checks current roots and caches witnesses for this session. - const client = walletClient.extend( + const client = coordinated.extend( shieldSwapActions({ api: { baseUrl: apiUrl }, blindedIdentities }), ) try { diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index 2d04005b..8db72dc9 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -1347,3 +1347,12 @@ await client.collect({ The CLI and agent/MCP write handlers use the SDK default generator. Existing providers remain compatible, including explicit legacy empty-witness providers for deployments that accept them. Omission now checks the actual current list. + +## Record inventory + +Apply core's `recordActions` before `shieldSwapActions` to coordinate maintenance +with trading. `planInventory({ token, target })` and +`rebalanceInventory({ token, target })` resolve the DEX token's underlying program +and delegate to core's ARC20/ARC22/credits helpers. Participating clients must +share the same store and chain identity. No database dependency is added to the +SDK. See the [inventory guide](../../site/docs/guides/record-inventory.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..ba999a1a --- /dev/null +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -0,0 +1,39 @@ +import { planRecordInventory, getCode, checkProgramConformance, type Client, type InventoryPlan, type InventoryTarget, type RecordAsset } from '@provablehq/veil-core' +import type { ApiClient } from '../../api/client.js' +import { tokenData } from '../../utils/tokens.js' +import { detectTokenStandard } from '../../utils/detectTokenStandard.js' + +/** + * Defines the DEX token and desired spendable record shape. + * @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, optionally extended with recordActions. + * @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 + * const plan = await planInventory(client, { token: 'ALEO', target: { records: 4 }, api }) + */ +export async function planInventory(client: Client, params: PlanInventoryParameters): Promise { + const token = await tokenData(params.api, params.token) + const program = token.underlyingProgram + if (!program) throw new Error(`Token ${token.symbol} has no underlying record program`) + let standard: RecordAsset['standard'] | 'none' = program === 'credits.aleo' ? 'credits' : await detectTokenStandard(client, { programId: program, engine: 'pure' }) + if (standard === 'none') { + const source = await getCode(client, { programId: program }) + for (const candidate of ['arc22', 'arc20'] as const) { + if (checkProgramConformance(source, candidate).violations.every((violation) => violation.kind === 'missing_view')) { standard = candidate; break } + } + } + if (standard === 'none') throw new Error(`Unsupported inventory asset: ${program}`) + const asset: RecordAsset = { program, standard } + return planRecordInventory(client, { asset, 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..59d21e8d --- /dev/null +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -0,0 +1,20 @@ +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 extended with recordActions before shieldSwapActions. + * @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 + * const result = await rebalanceInventory(client, { token: 'ALEO', target: { records: 4 }, api }) + */ +export async function rebalanceInventory(client: Client, params: RebalanceInventoryParameters): Promise { + const plan = await planInventory(client, params) + return rebalanceRecordInventory(client, { ...params, plan }) +} 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..5a2576d9 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' /** @@ -104,9 +104,11 @@ export async function selectTokenRecord(client: Client, params: SelectTokenRecor statusFilter: 'unspent', })) as OwnedRecord[] + const reserved = await reservedRecordIds(client) let best: TokenRecordInfo | undefined 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 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..e0338b30 --- /dev/null +++ b/packages/shield-swap/test/actions/inventory.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { 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' }) + 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 = Object.assign(base, recordActions({ store })(base)) + expect((await selectTokenRecord(client, { program, minAmount: 400n })).amount).toBe(700n) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93eecdc2..ddf7dd8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,15 @@ importers: specifier: ^4.21.0 version: 4.21.0 + packages/cli: + dependencies: + '@provablehq/veil-aleo-sdk': + specifier: workspace:^ + version: link:../provable-sdk + '@provablehq/veil-core': + specifier: workspace:^ + version: link:../core + packages/codegen: dependencies: '@provablehq/veil-core': @@ -251,6 +260,13 @@ importers: '@provablehq/veil-aleo-sdk': specifier: workspace:^ version: link:../provable-sdk + '@provablehq/veil-core': + specifier: workspace:^ + version: link:../core + optionalDependencies: + '@provablehq/veil-cli': + specifier: workspace:^ + version: link:../cli 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..7e930d13 --- /dev/null +++ b/site/docs/guides/record-inventory.md @@ -0,0 +1,106 @@ +# Record inventory + +A private token balance can span many records. A swap may need one record large +enough to spend, while concurrent trades benefit from several independent +records. Inventory actions join and split existing records to shape that balance. + +## Add inventory actions + +```ts +import { recordActions } from '@provablehq/veil-core' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' + +const aleo = await loadNetwork('testnet') +const { walletClient } = aleo.createAleoClient({ + privateKey, // supplied by the application + records: aleo.createRemoteScanner({ waitForSync: true }), +}) +const client = walletClient.extend(recordActions({ chainId: 'aleo:testnet' })) +const asset = { program: 'credits.aleo', standard: 'credits' } as const +const plan = await client.planRecordInventory({ + asset, + target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, + maxTransactions: 10, +}) +// Review plan.steps and plan.deduction before explicitly executing. +const result = await client.rebalanceRecordInventory({ + plan, maxFeeMicrocredits: 1_000_000n, +}) +``` + +Core provides runtime-independent planning, execution, and storage interfaces. +The Aleo SDK supplies the existing record scanner and local/delegated proving. +The default reservation store is in memory. Long-running services MUST supply +an appropriate durable `RecordInventoryStore`; SQLite is available exclusively +from the separate `@provablehq/veil-cli/storage` application package. + +| Action | Behavior | +| --- | --- | +| `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | +| `joinRecords({ asset, records: [a, b] })` | Joins two eligible records and returns the transaction id. | +| `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | +| `autoJoin({ asset, minAmount? })` | Consolidates enough records to cover an amount, or all records when omitted. | +| `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | +| `rebalanceRecordInventory({ plan, ... })` | Executes sequentially and waits for confirmed scanner outputs. | +| `reconcileRecordInventory({ rebroadcast? })` | Resolves pending journal entries; optional exact-transaction rebroadcast. | + +Assets use `standard: 'credits'`, `'arc20'`, or `'arc22'`. Credits MUST use +`credits.aleo`. ARC tokens are checked against their deployed Token and join/split +interfaces. Compliance, registry, and recipient-bound records are excluded. +Amounts use `bigint`: u64 microcredits for credits and u128 base units for tokens. +A native credits split deducts 10,000 microcredits from its outputs, separately +from any transaction fee. Plans report the total intrinsic deduction. + +`target.records` is 1–1000. `minRecordAmount` defaults to `1n`; +`distribution` defaults to `preserve`. `balanced` aims for equal amounts with +`toleranceBps` defaulting to 1000 (10%). The planner preserves already suitable +records and direct balanced splits where possible, otherwise consolidates and +splits. It is a bounded deterministic strategy, not an optimal transaction +solver; it rejects layouts whose required intermediate joins overflow the token +width. `maxTransactions` defaults to 100. + +## Coordinate with Shield Swap + +```ts +const dex = client.extend(shieldSwapActions({ api: {} })) +const plan = await dex.planInventory({ token: 'USDCx', target: { records: 4 } }) +const result = await dex.rebalanceInventory({ token: 'USDCx', target: { records: 4 } }) +``` + +Import `shieldSwapActions` from `@provablehq/shield-swap-sdk`. The wrapper resolves +token metadata to `underlyingProgram`, then uses the same core actions. +Apply `recordActions` first. Token selection skips shared reservations and +`writeContract`/`executeTransaction`/`executeContract` reserve explicit record +inputs atomically before proving. Independent clients MUST share the same store +and chain identity. This coordination does not cover raw submissions or other +wallets that do not participate. + +## Interrupted work and wallets + +Execution returns `complete` or `interrupted`, with known transaction ids and +completed steps. No accepted transaction is rolled back. Reconcile, scan, and +plan again after interruption; do not blindly replay an old plan. The executor +checks each input and only feeds confirmed output commitments into later steps. +Defaults are a 120-second confirmation/output-visibility deadline per step and +one-second polling; proving time is additional. Cancellation stops subsequent +work and retains unresolved reservations. + +A local prover MUST honor the awaited `transaction-prepared` progress callback +before broadcasting. Bundled SDK adapters do this for local and delegated +proving; delegated proving requests `broadcast: false` when checkpoints are +needed. The callback persists the transaction and checks the configured fee +ceiling. Custom `ProvingConfig.execute` adapters must follow this same contract. + +Wallet clients need granted plaintext or record fields (`$nonce`, amount, +record name, and `recipient_bound` when applicable), plus a pinned record uid +for spending without plaintext. Dependent execution also needs output commitment +metadata. Missing grants produce an error rather than a partial inventory. +Opaque wallet submission cannot enforce a fee ceiling before broadcast, so +wallet execution rejects configured fee budgets. A lost wallet response without +a transaction id retains its reservation for external reconciliation. + +Private fee selection is rejected on managed writes because its inputs are +selected outside this reservation mechanism. Use public fees or FeeMaster. + +For a supervised background process with policies, cooldowns, budgets, and a +restart journal, use the [inventory CLI](../packages/cli.md). diff --git a/site/docs/packages/cli.md b/site/docs/packages/cli.md new file mode 100644 index 00000000..ea4b1245 --- /dev/null +++ b/site/docs/packages/cli.md @@ -0,0 +1,18 @@ +# Veil CLI + +`@provablehq/veil-cli` supplies the `veil inventory` application for scanner-backed +ARC20, ARC22, and native credits record maintenance. Requires Node 22.13+; the +SDK packages retain their existing runtime support. + +```sh +veil inventory plan --asset credits.aleo --records 4 +veil inventory run --config inventory.json --execute +``` + +Commands are `inspect`, `plan`, `rebalance`, `run`, and `status`. Writes require +`--execute`; otherwise the application prints plans. Run the foreground loop +under a service manager for background operation. Its SQLite journal belongs to +the CLI package; core exposes an injectable store interface and in-memory default. + +See the [CLI configuration and recovery reference](https://github.com/ProvableHQ/veil/blob/main/packages/cli/README.md) +and [inventory SDK guide](../guides/record-inventory.md). diff --git a/site/sidebars.ts b/site/sidebars.ts index 90ab450b..46527f47 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', @@ -39,6 +40,7 @@ const sidebars: SidebarsConfig = { items: [ 'packages/overview', 'packages/core', + 'packages/cli', 'packages/provable-sdk', 'packages/wallet-adapter', 'packages/react', diff --git a/tsconfig.json b/tsconfig.json index 256a2262..eb30a2a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "isolatedModules": true, "noUncheckedIndexedAccess": true, "paths": { + "@provablehq/veil-cli/storage": ["./packages/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..21ec6ed5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ }, resolve: { alias: { + '@provablehq/veil-cli/storage': path.resolve(__dirname, 'packages/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'), From 4d2da4c8dba35dd403cfe721fd4b489af27c39de Mon Sep 17 00:00:00 2001 From: Roee-87 Date: Wed, 7 Oct 2026 14:19:48 -0700 Subject: [PATCH 02/32] [Feat] Batch token inventory joins through configurable autojoin routers --- .changeset/record-inventory.md | 2 + apps/loyalty-dapp/src/lib/aleo.ts | 2 +- examples/e2e-demo.ts | 2 +- packages/cli/README.md | 11 +- packages/cli/src/config.ts | 16 +- packages/cli/src/index.ts | 2 +- packages/cli/src/runner.ts | 2 +- packages/cli/test/runner.test.ts | 17 + packages/core/README.md | 8 +- packages/core/src/actions/records/autoJoin.ts | 4 +- .../core/src/actions/records/joinRecords.ts | 29 +- .../actions/records/planRecordInventory.ts | 5 +- .../records/rebalanceRecordInventory.ts | 9 +- packages/core/src/inventory/internal.ts | 2 + packages/core/src/inventory/planner.ts | 24 +- packages/core/src/inventory/recordActions.ts | 5 +- packages/core/src/inventory/spending.ts | 5 +- packages/core/src/inventory/tokenJoin.ts | 80 ++++ packages/core/src/inventory/types.ts | 17 +- .../fixtures/programs/main_aj_arc20_2_15.aleo | 389 ++++++++++++++++++ .../core/test/inventory/inventory.test.ts | 148 ++++++- packages/provable-sdk/README.md | 4 + packages/provable-sdk/src/index.ts | 4 +- .../provable-sdk/test/delegatedBuild.test.ts | 19 + packages/shield-swap-cli/src/session.ts | 3 +- packages/shield-swap/README.md | 5 + .../test/actions/inventory.test.ts | 16 + site/docs/guides/record-inventory.md | 32 +- 28 files changed, 812 insertions(+), 50 deletions(-) create mode 100644 packages/core/src/inventory/tokenJoin.ts create mode 100644 packages/core/test/fixtures/programs/main_aj_arc20_2_15.aleo diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index 95e21783..80f168b7 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -7,3 +7,5 @@ --- Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add the separate Veil inventory CLI with bounded maintenance policies and a CLI-only SQLite recovery journal. SDK packages retain their existing runtime requirements. + +Add configurable dynamic token join routing with batches of 2–15 records. Inventory plans preserve target counts, reservations, proved fee limits, and recovery while resolving dynamic router outputs to underlying token commitments. The CLI selects the network's deployed ARC router by default; native pairwise joins remain available. diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 30c45bd7..f416288d 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -74,5 +74,5 @@ export async function getLoyaltyContract(walletClient?: ReturnType) { - return walletClient.extend(recordActions({ chainId: 'aleo:mainnet' })) + return walletClient.extend(recordActions({ chainId: 'aleo:mainnet', tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } })) } diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index da8bc7bd..dfbf3b14 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -251,7 +251,7 @@ describe('Record inventory consumer example', () => { programName: 'credits.aleo', recordName: 'credits', tag: '1field', spent: false, recordPlaintext: `{ owner: ${account.address}.private, microcredits: 4030000u64.private, _nonce: 1group.public }`, }] }, - }).extend(recordActions({ chainId: 'aleo:testnet' })) + }).extend(recordActions({ chainId: 'aleo:testnet', tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) const plan = await client.planRecordInventory({ asset: { program: 'credits.aleo', standard: 'credits' }, target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, diff --git a/packages/cli/README.md b/packages/cli/README.md index da0ecb2d..a230ad17 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,6 +36,7 @@ Within this repository, use `pnpm veil inventory ...`. "maxFeeMicrocredits": "1000000", "maxDailyFeeMicrocredits": "10000000", "useFeeMaster": false, + "tokenJoin": { "program": "test_aj_arc20_2_15.aleo", "maxRecords": 15 }, "policies": [ { "asset": { "program": "credits.aleo", "standard": "credits" }, @@ -60,6 +61,12 @@ reuse the default identity across independent local chains. 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`: dynamic ARC20/ARC22 join router. Defaults to + `test_aj_arc20_2_15.aleo` on testnet or `main_aj_arc20_2_15.aleo` on mainnet. + Set `{ "program": "custom_router.aleo", "maxRecords": 15 }` for another + deployment, or `false` for native pairwise joins. The batch ceiling is 2–15. + Sixteen token records require two transactions; credits keep native joins. + 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. @@ -79,6 +86,7 @@ its reported synchronization before planning. 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 @@ -106,7 +114,8 @@ import { recordActions } from '@provablehq/veil-core' import { sqliteRecordInventoryStore } from '@provablehq/veil-cli/storage' const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') -const managed = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) +const managed = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet', + tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) // Extend managed with shieldSwapActions(...) when trading. // Close the store when the application shuts down. ``` diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 902e2502..6aa5ed42 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,4 +1,4 @@ -import type { InventoryTarget, RecordAsset } from '@provablehq/veil-core' +import type { InventoryTarget, RecordAsset, TokenJoinRouter } from '@provablehq/veil-core' /** * Defines one asset's maintenance policy in base units. @@ -27,6 +27,7 @@ export type InventoryPolicy = { * @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 Token join router; defaults to the network's ARC router. Set false to use native pairwise joins. * @internal */ export type InventoryConfig = { @@ -42,6 +43,7 @@ export type InventoryConfig = { maxFeeMicrocredits: bigint maxDailyFeeMicrocredits: bigint useFeeMaster: boolean + tokenJoin?: TokenJoinRouter } /** @@ -72,6 +74,16 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { } const network = raw.network ?? 'testnet' if (network !== 'testnet' && network !== 'mainnet') throw new Error('Network must be testnet or mainnet') + const router = raw.tokenJoin ?? { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' } + let tokenJoin: TokenJoinRouter | undefined + if (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') @@ -99,5 +111,5 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { 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 } + maxDailyFeeMicrocredits: amount(raw.maxDailyFeeMicrocredits, 10_000_000n), useFeeMaster: raw.useFeeMaster === true, tokenJoin } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 06e199ec..45b2740a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -46,7 +46,7 @@ async function main(): Promise { const { walletClient, account } = aleo.createAleoClient({ privateKey, networkUrl: config.networkUrl, useFeeMaster: config.useFeeMaster, records: aleo.createRemoteScanner({ waitForSync: true }) }) const store = await sqliteRecordInventoryStore(config.database) - const client = walletClient.extend(recordActions({ store, chainId: config.chainId, maxFeeMicrocredits: config.maxFeeMicrocredits })) + const client = walletClient.extend(recordActions({ store, chainId: config.chainId, maxFeeMicrocredits: config.maxFeeMicrocredits, tokenJoin: config.tokenJoin })) const scope = JSON.stringify([config.chainId, account.address]) let lease: string | undefined const controller = new AbortController() diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 7926d713..66947ffe 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -38,7 +38,7 @@ export async function runInventoryCycle(client: Client, config: InventoryConfig, const now = Date.now() 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.program === policy.asset.program && entry.status !== 'cancelled').reduce((last, entry) => Math.max(last, entry.createdAt), 0) + 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 diff --git a/packages/cli/test/runner.test.ts b/packages/cli/test/runner.test.ts index f5aa40be..9db4dfeb 100644 --- a/packages/cli/test/runner.test.ts +++ b/packages/cli/test/runner.test.ts @@ -8,6 +8,23 @@ const policy = { asset: { program: 'credits.aleo', standard: 'credits' }, target beforeEach(() => { vi.resetAllMocks(); vi.mocked(reconcileRecordInventory).mockResolvedValue([]) }) describe('inventory application policies', () => { + it('selects network routers and supports native or bounded custom joins', () => { + expect(parseInventoryConfig({}).tokenJoin).toEqual({ program: 'test_aj_arc20_2_15.aleo', maxRecords: 15 }) + expect(parseInventoryConfig({ network: 'mainnet' }).tokenJoin).toEqual({ program: 'main_aj_arc20_2_15.aleo', maxRecords: 15 }) + expect(parseInventoryConfig({ tokenJoin: false }).tokenJoin).toBeUndefined() + expect(parseInventoryConfig({ tokenJoin: { program: 'custom_router.aleo', maxRecords: 4 } }).tokenJoin?.maxRecords).toBe(4) + for (const tokenJoin of [true, { program: 'bad' }, { program: 'router.aleo', maxRecords: 1 }, { program: 'router.aleo', maxRecords: 16 }]) { + expect(() => parseInventoryConfig({ tokenJoin })).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') diff --git a/packages/core/README.md b/packages/core/README.md index 65ec44d4..cf4395fb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -248,9 +248,13 @@ const client = createPublicClient({ ## Record inventory -Extend a wallet client with `recordActions({ store?, chainId?, maxFeeMicrocredits? })` +Extend a wallet client with `recordActions({ store?, chainId?, maxFeeMicrocredits?, tokenJoin? })` for scanner-backed `joinRecords`, `splitRecord`, `autoJoin`, `planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. Supports ARC20, ARC22, and native credits. Core supplies a storage interface and in-memory -default; it has no SQLite or Node dependency. See the +default; it has no SQLite or Node dependency. Configure +`tokenJoin: { program: 'main_aj_arc20_2_15.aleo' }` on mainnet (or +`test_aj_arc20_2_15.aleo` on testnet) to batch 2–15 token records per join +transaction while preserving reservations and recovery. Omission retains native +pairwise joins; credits are unaffected. See the [record inventory guide](../../site/docs/guides/record-inventory.md). diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index a1d9be52..99d7f3b3 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -1,6 +1,6 @@ import type { Client } from '../../clients/createClient.js' import type { InventoryResult, RecordAsset } from '../../inventory/types.js' -import { scopeOf } from '../../inventory/internal.js' +import { management, scopeOf } from '../../inventory/internal.js' import { buildInventoryPlan } from '../../inventory/planner.js' import { getRecordInventory } from './getRecordInventory.js' import { rebalanceRecordInventory, type RebalanceRecordInventoryParameters } from './rebalanceRecordInventory.js' @@ -37,7 +37,7 @@ export async function autoJoin(client: Client, params: AutoJoinParameters): Prom if (sum < params.minAmount) throw new Error('Insufficient available balance for autoJoin') } if (records.length <= 1) return { status: 'complete', transactionIds: [], completedSteps: 0 } - const plan = buildInventoryPlan({ scope: scopeOf(client), asset: params.asset, target: { records: 1 }, + const plan = buildInventoryPlan({ scope: scopeOf(client), asset: params.asset, tokenJoin: management(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/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index 57766e59..aafef248 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -3,20 +3,22 @@ import type { InventoryRecord, RecordAsset } 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 } from '../../inventory/internal.js' +import { validateTokenJoinFunction } from '../../inventory/tokenJoin.js' /** - * Supplies two owned records for a native join. + * Supplies owned records for a native or configured batch join. * @property asset Program and supported standard. - * @property records Two distinct records from getRecordInventory. + * @property records Distinct records from getRecordInventory; exactly two for native joins, 2–15 with a token router. * @property onProgress Optional awaited checkpoint callback; defaults to no reporting. */ -export type JoinRecordsParameters = { asset: RecordAsset; records: [InventoryRecord, InventoryRecord]; onProgress?: ProvingProgressHandler } +export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryRecord[]; onProgress?: ProvingProgressHandler } /** - * Joins two records through the asset's native join transition. + * Joins records through a native transition or the configured token router. * Signs, proves and submits using the existing account and proving configuration. * @param client Wallet client; recordActions enables shared reservations. - * @param params Two distinct, same-owner records from getRecordInventory. + * @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 @@ -24,11 +26,16 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: [InventoryRec */ export async function joinRecords(client: Client, params: JoinRecordsParameters): Promise { const capabilities = await validateAsset(client, params.asset) - if (params.records.length !== 2) throw new Error('joinRecords requires two records') + const router = params.asset.standard !== 'credits' ? management(client)?.tokenJoin : undefined + if (router) await validateTokenJoinFunction(client, router, params.records.length) + else if (params.records.length !== 2) throw new Error('joinRecords requires two records without a token router') const records = params.records.map((record) => decodeRecord(client, params.asset, record.record, capabilities)) - const [a, b] = records - if (!a || !b || a.id === b.id) throw new Error('Join requires distinct eligible records owned by this account') - if (a.amount + b.amount > maxAmount(params.asset)) throw new Error('Joined amount overflows the token width') - return writeContract(client, { program: params.asset.program, function: 'join', - inputs: [recordInput(client, params.asset, a), recordInput(client, params.asset, b)], onProgress: params.onProgress }) + 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(params.asset)) throw new Error('Joined amount overflows the token width') + return writeContract(client, { program: router?.program ?? params.asset.program, function: router ? `join_${records.length}` : 'join', + inputs: [...(router ? [`'${params.asset.program.slice(0, -5)}'`] : []), ...eligible.map((record) => recordInput(client, params.asset, record))], + ...(router ? { imports: [params.asset.program] } : {}), onProgress: params.onProgress }) } diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts index 8ae9d258..6764f50b 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -1,6 +1,6 @@ import type { Client } from '../../clients/createClient.js' import type { InventoryPlan, InventoryTarget, RecordAsset } from '../../inventory/types.js' -import { scopeOf } from '../../inventory/internal.js' +import { management, scopeOf } from '../../inventory/internal.js' import { buildInventoryPlan } from '../../inventory/planner.js' import { getRecordInventory } from './getRecordInventory.js' @@ -24,5 +24,6 @@ export type PlanRecordInventoryParameters = { asset: RecordAsset; target: Invent */ export async function planRecordInventory(client: Client, params: PlanRecordInventoryParameters): Promise { const inventory = await getRecordInventory(client, { asset: params.asset }) - return buildInventoryPlan({ ...params, scope: scopeOf(client), inputs: inventory.available.map(({ id, amount }) => ({ id, amount })) }) + return buildInventoryPlan({ ...params, tokenJoin: management(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 index 134ad88b..1af0e59f 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -11,6 +11,7 @@ import { decodeRecord } 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. @@ -69,12 +70,12 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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, maxFeeMicrocredits: limit } }) as Client + const executionClient = Object.assign(Object.create(client), { recordManagement: { ...config, tokenJoin: plan.tokenJoin, maxFeeMicrocredits: limit } }) as Client 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 as [InventoryRecord, InventoryRecord], onProgress }) + ? await joinRecords(executionClient, { asset: plan.asset, records: inputs, 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) @@ -95,9 +96,7 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance throw new Error(`Transaction ${transactionId} was not accepted`) } await config.store.update(plan.scope, entry.id, { status: 'confirmed', transaction: undefined }) - const transition = (confirmed.transaction as Transaction).execution?.transitions.at(-1) - if (transition?.program !== plan.asset.program || transition.function !== step.kind) throw new Error('Confirmed transition does not match the inventory plan') - const commitments = (transition.outputs ?? []).filter((output) => output.type === 'record' || output.type === 'record_with_dynamic_id').map((output) => output.id) + 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[] = [] diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts index fcedf764..50425ac3 100644 --- a/packages/core/src/inventory/internal.ts +++ b/packages/core/src/inventory/internal.ts @@ -9,12 +9,14 @@ import { holdsRecords } from './store.js' * @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 router used for token consolidation; absent uses native joins. * @property network Original transport network, checked against later network switches. * @internal */ export type RecordManagement = Required> & { store: RecordInventoryStore maxFeeMicrocredits?: bigint + tokenJoin?: RecordActionsConfig['tokenJoin'] network: string | null | undefined } diff --git a/packages/core/src/inventory/planner.ts b/packages/core/src/inventory/planner.ts index 6d127e55..744fee53 100644 --- a/packages/core/src/inventory/planner.ts +++ b/packages/core/src/inventory/planner.ts @@ -1,6 +1,7 @@ -import type { InventoryPlan, InventoryStep, InventoryTarget, RecordAsset } from './types.js' +import type { InventoryPlan, InventoryStep, InventoryTarget, RecordAsset, TokenJoinRouter } from './types.js' import { positiveInteger } from './internal.js' import { maxAmount, splitDeduction } from '../actions/records/internal.js' +import { validateTokenJoinRouter } from './tokenJoin.js' /** * Computes a deterministic join/split dependency plan without network calls. @@ -11,9 +12,13 @@ import { maxAmount, splitDeduction } from '../actions/records/internal.js' */ export function buildInventoryPlan(params: { scope: string; asset: RecordAsset; target: InventoryTarget - inputs: { id: string; amount: bigint }[]; maxTransactions?: number + inputs: { id: string; amount: bigint }[]; maxTransactions?: number; tokenJoin?: TokenJoinRouter }): InventoryPlan { const { asset } = params + if (params.tokenJoin) validateTokenJoinRouter(params.tokenJoin) + const tokenJoin = asset.standard !== 'credits' && params.tokenJoin ? { ...params.tokenJoin } : undefined + const routing = tokenJoin ? { tokenJoin } : {} + const maxJoinRecords = tokenJoin ? validateTokenJoinRouter(tokenJoin) : 2 const target = { ...params.target } positiveInteger(target.records, 'records') const maxTransactions = params.maxTransactions ?? 100 @@ -61,7 +66,7 @@ export function buildInventoryPlan(params: { record = steps.at(-1)!.outputs[1]! } } - return { scope: params.scope, asset: { ...asset }, target, inputs, steps, + return { scope: params.scope, asset: { ...asset }, target, inputs, steps, ...routing, deduction: steps.reduce((sum, step) => sum + step.deduction, 0n), maxTransactions } } } @@ -73,14 +78,17 @@ export function buildInventoryPlan(params: { const reduceTo = repack ? 1 : target.records while (working.length > reduceTo) { working.sort((a, b) => a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : a.id.localeCompare(b.id)) - const [a, b] = working + let count = Math.min(maxJoinRecords, working.length - reduceTo + 1) + let amount = working.slice(0, count).reduce((sum, record) => sum + record.amount, 0n) + // Keep the batch within u128 and leave the requested number of records. + while (count > 2 && amount > maxAmount(asset)) amount -= working[--count]!.amount + const consumed = working.slice(0, count) // Intermediate joins can be below the output minimum while accumulating dust. - const amount = a!.amount + b!.amount 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', inputs: [a!.id, b!.id], outputs: [output], deduction: 0n }) - working = working.slice(2).concat(output) + steps.push({ kind: 'join', inputs: consumed.map((record) => record.id), outputs: [output], deduction: 0n }) + working = working.slice(count).concat(output) } while (working.length < target.records) { working.sort((a, b) => a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : a.id.localeCompare(b.id)) @@ -93,6 +101,6 @@ export function buildInventoryPlan(params: { } if (working.some((record) => record.amount < min)) throw new Error('Insufficient balance after split deductions') } - return { scope: params.scope, asset: { ...asset }, target, inputs, steps, + 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 index 5eb5e183..4a80a287 100644 --- a/packages/core/src/inventory/recordActions.ts +++ b/packages/core/src/inventory/recordActions.ts @@ -11,6 +11,7 @@ import { splitRecord, type SplitRecordParameters } from '../actions/records/spli import { autoJoin, type AutoJoinParameters } from '../actions/records/autoJoin.js' import { writeContract, type WriteContractParameters } from '../actions/wallet/writeContract.js' import { executeContract, type ExecuteContractParameters } from '../actions/wallet/executeContract.js' +import { validateTokenJoinRouter } from './tokenJoin.js' /** * Adds record inventory actions and shared spending coordination to an account client. @@ -27,7 +28,9 @@ export function recordActions(config: RecordActionsConfig = {}) { const chainId = config.chainId ?? client.transport.config.network if (!chainId) throw new Error('recordActions requires chainId on a transport without a network') if (config.maxFeeMicrocredits !== undefined && config.maxFeeMicrocredits < 0n) throw new Error('Fee limit cannot be negative') - const recordManagement: RecordManagement = { store, chainId, network: client.transport.config.network, maxFeeMicrocredits: config.maxFeeMicrocredits } + const tokenJoin = config.tokenJoin && { ...config.tokenJoin } + if (tokenJoin) validateTokenJoinRouter(tokenJoin) + const recordManagement: RecordManagement = { store, chainId, network: client.transport.config.network, maxFeeMicrocredits: config.maxFeeMicrocredits, tokenJoin } const scoped = Object.assign(Object.create(client), { recordManagement }) as Client return { recordManagement, diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts index 6951bfae..1c755e75 100644 --- a/packages/core/src/inventory/spending.ts +++ b/packages/core/src/inventory/spending.ts @@ -24,6 +24,7 @@ export function transactionFee(tx: Transaction): bigint { /** 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 + imports?: string[] | Record onProgress?: ProvingProgressHandler }, submit: (onProgress: ProvingProgressHandler) => Promise): Promise { const config = management(client)! @@ -47,7 +48,9 @@ export async function manageRecordSpend(client: Client, params: { } } if (new Set(ids).size !== ids.length) throw new Error('A transaction cannot spend the same record twice') - const entry = await config.store.acquire({ scope, records: ids, program: params.program, + const assetProgram = config.tokenJoin?.program === params.program && /^join_\d+$/.test(params.function) && Array.isArray(params.imports) + ? params.imports[0] : undefined + const entry = await config.store.acquire({ scope, records: ids, program: params.program, ...(assetProgram ? { assetProgram } : {}), function: params.function, status: 'reserved', createdAt: Date.now(), accountType: client.account?.type === 'local' ? 'local' : 'rpc' }) if (!entry) throw new Error('Record inventory changed: an input is already reserved or spent') let prepared = false diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts new file mode 100644 index 00000000..b03ea023 --- /dev/null +++ b/packages/core/src/inventory/tokenJoin.ts @@ -0,0 +1,80 @@ +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`) + 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 and standard from the reviewed plan. + * @param step Planned join or split whose outputs must be matched. + * @param router Optional captured token router; omission expects a native transition. + * @returns Record commitments in planned output order. + * @throws When the outer call or dynamic 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 ?? [] + const outer = transitions.at(-1) + const routed = step.kind === 'join' && asset.standard !== 'credits' && router + if (outer?.program !== (routed ? router.program : asset.program) || + outer.function !== (routed ? `join_${step.inputs.length}` : step.kind)) { + throw new Error('Confirmed transition does not match the inventory plan') + } + if (!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') + // A router returns a dynamic id, not a scanner commitment. Only the matching + // nested token join exposes the encrypted record and its actual commitment. + const matches = transitions.filter((transition) => transition.program === asset.program && transition.function === 'join') + .flatMap((transition) => transition.outputs ?? []) + .filter((output) => output.type === 'record_with_dynamic_id' && output.dynamic_id === outputs[0]!.id) + if (matches.length !== 1) throw new Error('Cannot resolve confirmed router output to the underlying token commitment') + return [matches[0]!.id] +} diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts index 950f951f..2919cff1 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -10,6 +10,15 @@ import type { Transaction } from '../types/transaction.js' */ export type RecordAsset = { program: string; standard: 'credits' | 'arc20' | 'arc22' } +/** + * 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 spendable record without requiring plaintext from a wallet. * @property id Record nonce, stable across scanners and wallet connections. @@ -35,7 +44,7 @@ export type InventoryTarget = { /** * 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. + * @property inputs Input nonces or previous step output references; joins consume two native records or 2–15 routed token records. * @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. @@ -57,6 +66,7 @@ export type InventoryStep = { * @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 token router captured at planning time; absent uses native pairwise joins. */ export type InventoryPlan = { scope: string @@ -66,6 +76,7 @@ export type InventoryPlan = { steps: InventoryStep[] deduction: bigint maxTransactions: number + tokenJoin?: TokenJoinRouter } /** Tracks reservation and transaction recovery states. */ @@ -78,6 +89,7 @@ export type RecordSpendStatus = 'reserved' | 'prepared' | 'submitted' | 'confirm * @property records Reserved record nonces. * @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. @@ -91,6 +103,7 @@ export type RecordSpend = { records: string[] program: string function: string + assetProgram?: string status: RecordSpendStatus createdAt: number accountType: 'local' | 'rpc' @@ -122,11 +135,13 @@ export type RecordInventoryStore = { * @property chainId Stable chain identity shared by all participants. Defaults to transport network; * custom/local chains MUST supply a distinct identity. * @property maxFeeMicrocredits Maximum proved transaction fee, before broadcast. Optional, no limit by default. + * @property tokenJoin Optional dynamic router for ARC20/ARC22 joins. Defaults to native pairwise joins; credits are unaffected. */ export type RecordActionsConfig = { store?: RecordInventoryStore chainId?: string maxFeeMicrocredits?: bigint + tokenJoin?: TokenJoinRouter } /** 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/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index c8b885d8..3f37f3c7 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { readFileSync } from 'node:fs' -import { createWalletClient, recordActions, memoryRecordInventoryStore, parseRecord, type OwnedRecord, type RecordAsset } from '../../src/index.js' +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' } @@ -8,13 +8,16 @@ 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) { +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 @@ -26,7 +29,7 @@ function world(amounts: bigint[], asset = credits) { 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') return source(asset) + if (method === 'getProgram') return params.programId === tokenJoin?.program ? routerSource : source(asset) if (method === 'getConfirmedTransaction') { const transaction = transactions.get(params.id) if (!transaction) throw Object.assign(new Error('Not found'), { status: 404 }) @@ -46,12 +49,19 @@ function world(amounts: bigint[], asset = credits) { 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 === 'join' ? [value(inputs[0]) + value(inputs[1])] : + 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: [{ id: `au1${sequence}`, program: asset.program, function: params.functionName, + 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: `dynamic${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 @@ -59,7 +69,7 @@ function world(amounts: bigint[], asset = credits) { 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() {} } }).extend(recordActions({ store })) + recordProvider: { requestRecords: scan, setAccount() {} } }).extend(recordActions({ store, tokenJoin })) return { client, store, build, send, scan, transactions, records, setRecords: (value: OwnedRecord[]) => { records = value } } } @@ -251,3 +261,129 @@ describe('inventory execution and recovery', () => { 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' + throw new Error(method) + }) + const wallet = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any }).extend(recordActions({ 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 an unrelated or mismatched dynamic output instead of selecting intermediate records', 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(-1).outputs[0].id = 'unrelatedfield' + 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/provable-sdk/README.md b/packages/provable-sdk/README.md index ba678ac5..22d28d07 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -171,3 +171,7 @@ for existing consumers. Both local and delegated execution honor an awaited checkpoint a proved transaction. Storage is supplied by the application; SQLite belongs only to the separate Veil CLI. See the [inventory guide](../../site/docs/guides/record-inventory.md). + +Configured token join routers supply the underlying token as a dynamic import; +the proving adapter includes that program and its transitive static imports for +both local and delegated proofs. diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index 8411a4ce..91cd538d 100644 --- a/packages/provable-sdk/src/index.ts +++ b/packages/provable-sdk/src/index.ts @@ -678,7 +678,9 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo const staticImports = await programManager.networkClient.getProgramImports(programSource) const merged: SdkProgramImports = { ...staticImports } 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 } diff --git a/packages/provable-sdk/test/delegatedBuild.test.ts b/packages/provable-sdk/test/delegatedBuild.test.ts index 517a1bf4..9127775c 100644 --- a/packages/provable-sdk/test/delegatedBuild.test.ts +++ b/packages/provable-sdk/test/delegatedBuild.test.ts @@ -14,6 +14,25 @@ describe('delegated transaction building', () => { 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 () => { const transaction = { type: 'execute', id: 'at1proved', fee: {} } const provingRequest = vi.spyOn(testnetSdk.ProgramManager.prototype, 'provingRequest') diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index dc751fb5..2455a6af 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -264,7 +264,8 @@ export async function loadSession(options: { network?: string } = {}) { // Opt in to the same durable record journal as `veil inventory run`. const inventoryPath = process.env.VEIL_INVENTORY_DB const coordinated = inventoryPath - ? walletClient.extend(recordActions({ store: await (await import('@provablehq/veil-cli/storage')).sqliteRecordInventoryStore(inventoryPath), chainId: `aleo:${network}` })) + ? walletClient.extend(recordActions({ store: await (await import('@provablehq/veil-cli/storage')).sqliteRecordInventoryStore(inventoryPath), chainId: `aleo:${network}`, + tokenJoin: { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' } })) : walletClient // SDK proving supplies automatic freezelist witnesses for every write command; // shieldSwapActions checks current roots and caches witnesses for this session. diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index 8db72dc9..f854bab5 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -1350,6 +1350,11 @@ for deployments that accept them. Omission now checks the actual current list. ## Record inventory +Configure `recordActions({ tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } })` +before the DEX decorator to batch underlying ARC20/ARC22 joins on mainnet. Use +`test_aj_arc20_2_15.aleo` on testnet. The DEX inventory wrappers preserve this +configuration, target counts, shared reservations, and exact-output recovery. + Apply core's `recordActions` before `shieldSwapActions` to coordinate maintenance with trading. `planInventory({ token, target })` and `rebalanceInventory({ token, target })` resolve the DEX token's underlying program diff --git a/packages/shield-swap/test/actions/inventory.test.ts b/packages/shield-swap/test/actions/inventory.test.ts index e0338b30..9c8cc526 100644 --- a/packages/shield-swap/test/actions/inventory.test.ts +++ b/packages/shield-swap/test/actions/inventory.test.ts @@ -32,4 +32,20 @@ describe('Shield Swap inventory', () => { const client = Object.assign(base, recordActions({ store })(base)) 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 7e930d13..f3ab7888 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -15,7 +15,10 @@ const { walletClient } = aleo.createAleoClient({ privateKey, // supplied by the application records: aleo.createRemoteScanner({ waitForSync: true }), }) -const client = walletClient.extend(recordActions({ chainId: 'aleo:testnet' })) +const client = walletClient.extend(recordActions({ + chainId: 'aleo:testnet', + tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, +})) const asset = { program: 'credits.aleo', standard: 'credits' } as const const plan = await client.planRecordInventory({ asset, @@ -37,7 +40,7 @@ from the separate `@provablehq/veil-cli/storage` application package. | Action | Behavior | | --- | --- | | `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | -| `joinRecords({ asset, records: [a, b] })` | Joins two eligible records and returns the transaction id. | +| `joinRecords({ asset, records })` | Joins two native records, or 2–15 token records with a configured router; returns the transaction id. | | `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | | `autoJoin({ asset, minAmount? })` | Consolidates enough records to cover an amount, or all records when omitted. | | `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | @@ -59,6 +62,31 @@ splits. It is a bounded deterministic strategy, not an optimal transaction solver; it rejects layouts whose required intermediate joins overflow the token width. `maxTransactions` defaults to 100. +### Batch token consolidation + +Configure `recordActions({ tokenJoin: { program, maxRecords? } })` to route ARC20 +and ARC22 joins through a dedicated dynamic join program. Mainnet uses +`main_aj_arc20_2_15.aleo`; testnet uses `test_aj_arc20_2_15.aleo`. `maxRecords` +defaults to 15 and accepts 2–15. Core has no network-specific router default; +omitting `tokenJoin` preserves native pairwise joins. Credits remain native. + +The planner batches only as many inputs as the target count requires and bounds +each summed amount to u128. Sixteen token records consolidate through `join_15` +and `join_2`, rather than fifteen separate transactions. `maxTransactions` counts +submitted transactions, including batch joins and native splits. Plans capture +their router; existing native plans retain their original execution path. + +Each router call prepends the underlying program's identifier (for example, +`'test_usdcx_stablecoin'`) and supplies the token as a dynamic import. The Aleo SDK +resolves its transitive imports for both local and delegated proving. Wallet +adapters must support dynamic calls and pinned underlying-token records. + +All batch inputs are reserved atomically. Fees, durable proof checkpoints, and +exact-transaction recovery use the same inventory journal. Confirmed router +outputs carry dynamic ids; execution resolves each id to the matching nested +token join's record commitment before waiting for the scanner. Intermediate +records and unrelated deposits cannot satisfy a dependent step. + ## Coordinate with Shield Swap ```ts From 89be787c3f09ca70bb12bd404993f40aa3db9393 Mon Sep 17 00:00:00 2001 From: Roee-87 Date: Thu, 8 Oct 2026 17:14:30 -0700 Subject: [PATCH 03/32] Validate RPC commitment grants before rebalancing record inventory --- .../records/rebalanceRecordInventory.ts | 12 ++- .../core/test/inventory/inventory.test.ts | 76 +++++++++++++++++++ site/docs/guides/record-inventory.md | 4 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/core/src/actions/records/rebalanceRecordInventory.ts b/packages/core/src/actions/records/rebalanceRecordInventory.ts index 1af0e59f..b434db7f 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -28,6 +28,7 @@ export type RebalanceRecordInventoryParameters = { /** * 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 extended with recordActions for reservation and durable progress. * @param params Reviewed plan, timeouts, cancellation and optional fee budget. * @returns Completion or interruption with all submitted transaction ids; never rolls back accepted steps. @@ -50,6 +51,7 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance if (params.maxFeeMicrocredits !== undefined && params.maxFeeMicrocredits < 0n) throw new Error('Fee budget cannot be negative') const result: InventoryResult = { status: 'complete', transactionIds: [], completedSteps: 0 } 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() @@ -57,7 +59,7 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance } const pause = () => new Promise((resolve) => setTimeout(resolve, interval)) try { - for (const step of plan.steps) { + for (const [index, step] of plan.steps.entries()) { check() const inventory = await getRecordInventory(client, { asset: plan.asset }) const inputs = step.inputs.map((id) => { @@ -66,6 +68,14 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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() const remaining = params.maxFeeMicrocredits === undefined ? undefined : params.maxFeeMicrocredits - fees const perTransaction = config.maxFeeMicrocredits diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index 3f37f3c7..4ad46846 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -153,6 +153,82 @@ describe('scanner-backed inventory', () => { }) 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 }).extend(recordActions({ 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 } + throw new Error(method) + }) + const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, + transport: { ...w.client.transport, request } as any }).extend(recordActions({ 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 }).extend(recordActions({ 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 } diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index f3ab7888..3e19fe58 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -122,7 +122,9 @@ ceiling. Custom `ProvingConfig.execute` adapters must follow this same contract. Wallet clients need granted plaintext or record fields (`$nonce`, amount, record name, and `recipient_bound` when applicable), plus a pinned record uid for spending without plaintext. Dependent execution also needs output commitment -metadata. Missing grants produce an error rather than a partial inventory. +metadata (`commitment` or a `$commitment` grant). Rebalance checks consumed +records for this metadata before submitting its first step; missing metadata +returns an interrupted result with no submitted transactions. Opaque wallet submission cannot enforce a fee ceiling before broadcast, so wallet execution rejects configured fee budgets. A lost wallet response without a transaction id retains its reservation for external reconciliation. From cddd34c8a84a3a20a985da0f3c4c649c9c219726 Mon Sep 17 00:00:00 2001 From: Roee-87 Date: Thu, 8 Oct 2026 17:16:22 -0700 Subject: [PATCH 04/32] fixed readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6b1f21f6..00759261 100644 --- a/README.md +++ b/README.md @@ -246,14 +246,14 @@ const tools = aleoAgentTools({ // Each tool has { schema, handler } — plug into LangChain, Vercel AI SDK, etc. ``` -## License - -MIT - -### Record inventory management +## Record inventory management Core provides scanner-backed join, split, and inventory-rebalance actions for ARC20, ARC22, and native credits. Shield Swap resolves DEX tokens to their underlying programs. The separate [`@provablehq/veil-cli`](packages/cli/README.md) application supplies background maintenance and a CLI-only SQLite journal. Start with the [inventory guide](site/docs/guides/record-inventory.md). + +## License + +MIT From 1c85f1c1549cdb015ba08fd72b4f14c5e474f578 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 10:18:25 -0400 Subject: [PATCH 05/32] [Docs] Explain record inventory execution and recovery --- packages/cli/src/config.ts | 8 ++++++++ packages/cli/src/index.ts | 7 +++++++ packages/cli/src/runner.ts | 10 ++++++++++ packages/cli/src/storage.ts | 10 ++++++++++ packages/core/src/actions/records/autoJoin.ts | 4 ++++ .../src/actions/records/getRecordInventory.ts | 8 ++++++++ packages/core/src/actions/records/internal.ts | 6 ++++++ packages/core/src/actions/records/joinRecords.ts | 4 ++++ .../src/actions/records/planRecordInventory.ts | 2 ++ .../actions/records/rebalanceRecordInventory.ts | 14 ++++++++++++++ .../actions/records/reconcileRecordInventory.ts | 4 ++++ packages/core/src/actions/records/splitRecord.ts | 2 ++ .../core/src/actions/wallet/executeContract.ts | 4 ++++ .../core/src/actions/wallet/writeContract.ts | 4 ++++ packages/core/src/inventory/planner.ts | 12 ++++++++++++ packages/core/src/inventory/spending.ts | 16 ++++++++++++++++ packages/core/src/inventory/store.ts | 5 +++++ packages/core/src/inventory/tokenJoin.ts | 5 +++++ .../src/actions/inventory/planInventory.ts | 4 ++++ .../src/actions/inventory/rebalanceInventory.ts | 2 ++ packages/shield-swap/src/utils/records.ts | 2 ++ 21 files changed, 133 insertions(+) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 6aa5ed42..78c622ca 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -57,6 +57,8 @@ export type InventoryConfig = { 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') @@ -74,6 +76,8 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { } const network = raw.network ?? 'testnet' if (network !== 'testnet' && network !== 'mainnet') throw new Error('Network must be testnet or mainnet') + // CLI policies choose the deployed router for their network by default; an + // explicit false keeps native pairwise joins available for other deployments. const router = raw.tokenJoin ?? { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' } let tokenJoin: TokenJoinRouter | undefined if (router !== false) { @@ -87,6 +91,8 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { 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) || !['credits', 'arc20', 'arc22'].includes(asset.standard)) throw new Error('Invalid inventory asset') @@ -103,6 +109,8 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { 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) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 45b2740a..0a9cf05e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -34,6 +34,7 @@ async function main(): Promise { if (values.help || !positionals.length) { console.log(HELP); return } const [group, command] = positionals if (group !== 'inventory' || !command || !['inspect', 'plan', 'rebalance', 'run', 'status'].includes(command) || positionals.length !== 2) throw new Error(HELP) + // Apply explicit command-line overrides before validating the combined policy. const raw = values.config ? JSON.parse(await readFile(values.config, 'utf8')) : {} if (values.network) raw.network = values.network if (values.database || process.env.VEIL_INVENTORY_DB) raw.database = values.database ?? process.env.VEIL_INVENTORY_DB @@ -55,6 +56,7 @@ async function main(): Promise { process.once('SIGTERM', stop) try { if (command === 'status') { + // Print operation metadata without the stored proved transaction payload. print((await store.list(scope)).map(({ transaction: _transaction, ...entry }) => entry)); return } if (command === 'inspect') { @@ -66,10 +68,13 @@ async function main(): Promise { } return } + // All write commands stop at planning unless --execute was supplied. if (command === 'plan' || !values.execute) { for (const policy of config.policies) print(await client.planRecordInventory({ ...policy, maxTransactions: config.maxTransactions })) return } + // Reserve a synthetic record id to allow only one maintenance loop per + // account and chain, while ordinary trading uses the real 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') @@ -88,6 +93,8 @@ async function main(): Promise { await delay(config.intervalMs, undefined, { signal: controller.signal }).catch((error) => { if (!controller.signal.aborted) throw error }) } while (!controller.signal.aborted) } finally { + // Release only the manager lease on shutdown; pending transaction entries + // remain in the journal for the next process to reconcile. try { if (lease) await store.update(scope, lease, { status: 'cancelled' }) } finally { store.close() diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 66947ffe..f8c283f8 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -26,16 +26,22 @@ export type InventoryCycleResult = { program: string; status: string; result?: I * 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) return [{ program: '*', status: 'waiting', reason: 'Unresolved transactions retain their reservations' }] 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) @@ -45,6 +51,8 @@ export async function runInventoryCycle(client: Client, config: InventoryConfig, } try { const inventory = await getRecordInventory(client, { asset: policy.asset }) + // A count range deliberately tolerates inventory drift; preserve usable + // records instead of repeatedly reshaping them to the exact target. 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))) { @@ -52,6 +60,8 @@ export async function runInventoryCycle(client: Client, config: InventoryConfig, } 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 diff --git a/packages/cli/src/storage.ts b/packages/cli/src/storage.ts index c6432db9..c7c373db 100644 --- a/packages/cli/src/storage.ts +++ b/packages/cli/src/storage.ts @@ -15,15 +15,21 @@ import type { RecordInventoryStore, RecordSpend } from '@provablehq/veil-core' * const client = walletClient.extend(recordActions({ 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, @@ -36,6 +42,8 @@ export async function sqliteRecordInventoryStore(path: string): Promise(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 } @@ -61,6 +69,8 @@ export async function sqliteRecordInventoryStore(path: string): Promise 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') if (read(entry.scope).some((old) => old.status !== 'cancelled' && old.status !== 'rejected' && diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index 99d7f3b3..4e0fc800 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -30,6 +30,8 @@ export async function autoJoin(client: Client, params: AutoJoinParameters): Prom 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 @@ -37,6 +39,8 @@ export async function autoJoin(client: Client, params: AutoJoinParameters): Prom 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 plan = buildInventoryPlan({ scope: scopeOf(client), asset: params.asset, tokenJoin: management(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 index b0359bf3..609555f2 100644 --- a/packages/core/src/actions/records/getRecordInventory.ts +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -29,6 +29,8 @@ export type GetRecordInventoryReturnType = { available: InventoryRecord[]; reser * const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo', standard: 'credits' } }) */ 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() @@ -42,15 +44,21 @@ export async function getRecordInventory(client: Client, params: GetRecordInvent ...(client.account?.type === 'rpc' ? {} : { filter: { records: [recordName(params.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 { 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 index e9b333b6..824cf9e6 100644 --- a/packages/core/src/actions/records/internal.ts +++ b/packages/core/src/actions/records/internal.ts @@ -45,6 +45,8 @@ export function decodeRecord(client: Client, asset: RecordAsset, record: OwnedRe const field = asset.standard === 'credits' ? '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. @@ -54,6 +56,8 @@ export function decodeRecord(client: Client, asset: RecordAsset, record: OwnedRe 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') @@ -69,6 +73,8 @@ export function decodeRecord(client: Client, asset: RecordAsset, record: OwnedRe /** 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 } } diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index aafef248..7a6ee7fb 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -29,12 +29,16 @@ export async function joinRecords(client: Client, params: JoinRecordsParameters) const router = params.asset.standard !== 'credits' ? management(client)?.tokenJoin : undefined if (router) await validateTokenJoinFunction(client, router, params.records.length) else if (params.records.length !== 2) throw new Error('joinRecords requires two records without a token router') + // 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(params.asset)) throw new Error('Joined amount overflows the token width') + // Routed joins identify the underlying program explicitly and provide it as a + // dynamic import; native joins call the token's own pairwise transition. return writeContract(client, { program: router?.program ?? params.asset.program, function: router ? `join_${records.length}` : 'join', inputs: [...(router ? [`'${params.asset.program.slice(0, -5)}'`] : []), ...eligible.map((record) => recordInput(client, params.asset, record))], ...(router ? { imports: [params.asset.program] } : {}), onProgress: params.onProgress }) diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts index 6764f50b..ffb33380 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -24,6 +24,8 @@ export type PlanRecordInventoryParameters = { asset: RecordAsset; target: Invent */ export async function planRecordInventory(client: Client, params: PlanRecordInventoryParameters): Promise { const inventory = await getRecordInventory(client, { asset: params.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, tokenJoin: management(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 index b434db7f..68df2c6f 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -50,6 +50,8 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance positiveInteger(interval, 'pollIntervalMs', 2_147_483_647) if (params.maxFeeMicrocredits !== undefined && params.maxFeeMicrocredits < 0n) throw new Error('Fee budget cannot be negative') 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 @@ -61,6 +63,8 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance try { 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)) @@ -77,10 +81,14 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance } } 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) } @@ -94,6 +102,8 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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`) @@ -122,6 +132,8 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance }) 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) @@ -129,6 +141,8 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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 index ed7098b4..60fe6293 100644 --- a/packages/core/src/actions/records/reconcileRecordInventory.ts +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -28,6 +28,8 @@ export async function reconcileRecordInventory(client: Client, params: Reconcile if (!entry.transactionId) continue // An uncertain wallet response needs external reconciliation. let missing = false try { + // Only a definitive chain result changes the reservation state. A 404 + // does not prove that a prepared or submitted transaction was rejected. const result = await getConfirmedTransaction(client, { id: entry.transactionId }) if (result.status === 'accepted' || result.status === 'rejected') { await config.store.update(scope, entry.id, { status: result.status === 'accepted' ? 'confirmed' : 'rejected', transaction: undefined }) @@ -38,6 +40,8 @@ export async function reconcileRecordInventory(client: Client, params: Reconcile if ((error as { status?: number }).status !== 404) throw error missing = true } + // 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 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') diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts index df645896..c8f4735e 100644 --- a/packages/core/src/actions/records/splitRecord.ts +++ b/packages/core/src/actions/records/splitRecord.ts @@ -26,6 +26,8 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor export async function splitRecord(client: Client, params: SplitRecordParameters): Promise { const capabilities = await validateAsset(client, params.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(params.asset) >= record.amount) throw new Error('Split must produce two positive eligible records') return writeContract(client, { program: params.asset.program, function: 'split', diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index 5c80d306..2cc588de 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -81,6 +81,8 @@ export async function executeContract( params: ExecuteContractParameters, ): Promise { if (management(client)) { + // Copy the inputs before asynchronous reservation checks, then run the usual + // execution path with journaling callbacks and without acquiring twice. const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } return manageRecordSpend(client, snapshot, (onProgress) => executeContract(withoutManagement(client), { ...snapshot, onProgress })) @@ -122,6 +124,8 @@ export async function executeContract( // Local proving resolves no wallet-side inputs — only encoded strings. assertNoInputRequests(params.inputs) + // The prover owns build, broadcast, and confirmation on this path; forward + // the awaited callback so it can checkpoint before crossing each boundary. if (client.proving?.execute) { return client.proving.execute({ programName: params.program, diff --git a/packages/core/src/actions/wallet/writeContract.ts b/packages/core/src/actions/wallet/writeContract.ts index c653b45f..41d6492a 100644 --- a/packages/core/src/actions/wallet/writeContract.ts +++ b/packages/core/src/actions/wallet/writeContract.ts @@ -70,6 +70,8 @@ export async function writeContract( params: WriteContractParameters, ): Promise { if (management(client)) { + // Copy the inputs before asynchronous reservation checks, then run the usual + // execution path with journaling callbacks and without acquiring twice. const snapshot = { ...params, inputs: params.inputs.map((input) => typeof input === 'string' ? input : { ...input }) } return manageRecordSpend(client, snapshot, (onProgress) => writeContract(withoutManagement(client), { ...snapshot, onProgress })) @@ -113,6 +115,8 @@ export async function writeContract( ...(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/inventory/planner.ts b/packages/core/src/inventory/planner.ts index 744fee53..eaf4acb6 100644 --- a/packages/core/src/inventory/planner.ts +++ b/packages/core/src/inventory/planner.ts @@ -14,6 +14,8 @@ export function buildInventoryPlan(params: { scope: string; asset: RecordAsset; target: InventoryTarget inputs: { id: string; amount: bigint }[]; maxTransactions?: number; tokenJoin?: TokenJoinRouter }): InventoryPlan { + // Capture the requested policy and validate bounds before simulating any steps. + // Credits always use native pairwise joins, even when a token router is configured. const { asset } = params if (params.tokenJoin) validateTokenJoinRouter(params.tokenJoin) const tokenJoin = asset.standard !== 'credits' && params.tokenJoin ? { ...params.tokenJoin } : undefined @@ -34,6 +36,8 @@ export function buildInventoryPlan(params: { 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 @@ -42,6 +46,8 @@ export function buildInventoryPlan(params: { }) 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') @@ -54,6 +60,8 @@ export function buildInventoryPlan(params: { // 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 && @@ -76,6 +84,8 @@ export function buildInventoryPlan(params: { 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)) let count = Math.min(maxJoinRecords, working.length - reduceTo + 1) @@ -90,6 +100,8 @@ export function buildInventoryPlan(params: { steps.push({ kind: 'join', 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]! diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts index 1c755e75..cf7ac300 100644 --- a/packages/core/src/inventory/spending.ts +++ b/packages/core/src/inventory/spending.ts @@ -8,12 +8,16 @@ import { inventoryRecordId, management, scopeOf } from './internal.js' /** Builds an internal client view that avoids recursive reservation acquisition. @internal */ export function withoutManagement(client: Client): Client { + // Preserve the original account, transport, and prover without mutating the + // caller's client; only the nested action skips reservation acquisition. return Object.assign(Object.create(client), { recordManagement: undefined }) as Client } /** Reads the public base and priority fees in microcredits; rejects an unverifiable fee. @internal */ export function transactionFee(tx: Transaction): bigint { if (!tx.fee) return 0n + // Public fee inputs carry the base and priority fees. An opaque or malformed + // fee cannot be compared safely with a pre-broadcast spending limit. const values = tx.fee.transition.inputs?.slice(0, 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') @@ -31,10 +35,14 @@ export async function manageRecordSpend(client: Client, params: { if (params.privateFee) throw new Error('Managed records require public fees or FeeMaster; private fee selection is not coordinated') 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') @@ -48,8 +56,11 @@ export async function manageRecordSpend(client: Client, params: { } } 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.tokenJoin?.program === params.program && /^join_\d+$/.test(params.function) && Array.isArray(params.imports) ? params.imports[0] : undefined + // Acquire all input nonces together before invoking any proving or wallet call. const entry = await config.store.acquire({ scope, records: ids, program: params.program, ...(assetProgram ? { assetProgram } : {}), function: params.function, status: 'reserved', createdAt: Date.now(), accountType: client.account?.type === 'local' ? 'local' : 'rpc' }) if (!entry) throw new Error('Record inventory changed: an input is already reserved or spent') @@ -60,6 +71,8 @@ export async function manageRecordSpend(client: Client, params: { // 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. const fee = transactionFee(event.transaction) if (config.maxFeeMicrocredits !== undefined && fee > config.maxFeeMicrocredits) { throw new Error('Proved transaction exceeds maxFeeMicrocredits') @@ -68,11 +81,14 @@ export async function manageRecordSpend(client: Client, params: { 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) }) } catch (error) { diff --git a/packages/core/src/inventory/store.ts b/packages/core/src/inventory/store.ts index cc8a852a..8d616232 100644 --- a/packages/core/src/inventory/store.ts +++ b/packages/core/src/inventory/store.ts @@ -14,6 +14,8 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { 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) && old.records.some((id) => entry.records.includes(id)))) return undefined @@ -25,6 +27,8 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { 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 clear a completed proof instead + // of retaining the old transaction when the patch requests its removal. if ('transaction' in patch && patch.transaction === undefined) delete entry.transaction }, } @@ -32,5 +36,6 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { /** 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' } diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts index b03ea023..bfb316b4 100644 --- a/packages/core/src/inventory/tokenJoin.ts +++ b/packages/core/src/inventory/tokenJoin.ts @@ -35,6 +35,8 @@ export function validateTokenJoinRouter(router: TokenJoinRouter): number { 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] @@ -61,12 +63,15 @@ export async function validateTokenJoinFunction(client: Client, router: TokenJoi */ 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 routed = step.kind === 'join' && asset.standard !== 'credits' && router if (outer?.program !== (routed ? router.program : asset.program) || outer.function !== (routed ? `join_${step.inputs.length}` : step.kind)) { throw new Error('Confirmed transition does not match the inventory plan') } + // Native transitions already expose the commitments that scanners index. if (!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') diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts index ba999a1a..08bdaec4 100644 --- a/packages/shield-swap/src/actions/inventory/planInventory.ts +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -24,10 +24,14 @@ export type PlanInventoryParameters = { token: string; target: InventoryTarget; */ 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`) let standard: RecordAsset['standard'] | 'none' = program === 'credits.aleo' ? 'credits' : await detectTokenStandard(client, { programId: program, engine: 'pure' }) if (standard === 'none') { + // Some deployed tokens omit newer read views but retain the required token + // interface; tolerate only those missing views, not other conformance errors. const source = await getCode(client, { programId: program }) for (const candidate of ['arc22', 'arc20'] as const) { if (checkProgramConformance(source, candidate).violations.every((violation) => violation.kind === 'missing_view')) { standard = candidate; break } diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts index 59d21e8d..8c2a680f 100644 --- a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -15,6 +15,8 @@ export type RebalanceInventoryParameters = PlanInventoryParameters & Omit { + // Resolve the DEX token and build its underlying-asset plan before handing + // execution, reservations, and dependent-output checks to the core actions. const plan = await planInventory(client, params) return rebalanceRecordInventory(client, { ...params, plan }) } diff --git a/packages/shield-swap/src/utils/records.ts b/packages/shield-swap/src/utils/records.ts index 5a2576d9..54570c50 100644 --- a/packages/shield-swap/src/utils/records.ts +++ b/packages/shield-swap/src/utils/records.ts @@ -104,6 +104,8 @@ export async function selectTokenRecord(client: Client, params: SelectTokenRecor statusFilter: 'unspent', })) as OwnedRecord[] + // Exclude inputs held by participating inventory or trading operations even + // if the scanner still reports those records as unspent. const reserved = await reservedRecordIds(client) let best: TokenRecordInfo | undefined for (const record of records) { From 571f2e7d639bfc54a922f0db6c5b0147ab82eeba Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 10:18:42 -0400 Subject: [PATCH 06/32] [Fix] Require explicit record management configuration --- apps/loyalty-dapp/src/lib/aleo.ts | 11 +- examples/e2e-demo.ts | 8 +- packages/cli/README.md | 19 ++- packages/cli/src/index.ts | 9 +- packages/cli/src/runner.ts | 2 +- packages/cli/src/storage.ts | 5 +- packages/core/README.md | 25 ++-- packages/core/src/actions/records/autoJoin.ts | 6 +- .../src/actions/records/getRecordInventory.ts | 2 +- .../core/src/actions/records/joinRecords.ts | 6 +- .../actions/records/planRecordInventory.ts | 4 +- .../records/rebalanceRecordInventory.ts | 4 +- .../records/reconcileRecordInventory.ts | 2 +- .../core/src/actions/records/splitRecord.ts | 2 +- packages/core/src/clients/createClient.ts | 10 +- .../core/src/clients/createPublicClient.ts | 2 +- .../core/src/clients/createWalletClient.ts | 24 +++- .../core/src/clients/decorators/wallet.ts | 5 +- packages/core/src/inventory/internal.ts | 54 +++++++-- packages/core/src/inventory/recordActions.ts | 62 +++++----- packages/core/src/inventory/types.ts | 32 ++++- .../core/test/inventory/configuration.test.ts | 111 ++++++++++++++++++ .../core/test/inventory/inventory.test.ts | 12 +- packages/provable-sdk/README.md | 8 +- packages/provable-sdk/src/index.ts | 26 +++- packages/provable-sdk/test/provable.test.ts | 21 ++++ packages/shield-swap-cli/README.md | 8 +- packages/shield-swap-cli/src/session.ts | 19 +-- packages/shield-swap/README.md | 11 +- .../src/actions/inventory/planInventory.ts | 2 +- .../actions/inventory/rebalanceInventory.ts | 2 +- .../test/actions/inventory.test.ts | 5 +- site/docs/guides/record-inventory.md | 54 ++++++--- 33 files changed, 435 insertions(+), 138 deletions(-) create mode 100644 packages/core/test/inventory/configuration.test.ts diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index f416288d..dd0a6597 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -12,7 +12,6 @@ import { fallback, getContract, parseProgram, - recordActions, } from '@provablehq/veil-core' import { fromWalletAdapter, type AleoWalletAdapter } from '@provablehq/veil-aleo-wallet-adapter' @@ -35,6 +34,8 @@ 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. + recordManagement: { enabled: false, chainId: 'aleo:mainnet', tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } }, }) } @@ -63,10 +64,10 @@ export async function getLoyaltyContract(walletClient?: ReturnType) { - return walletClient.extend(recordActions({ chainId: 'aleo:mainnet', tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } })) + return walletClient } diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index dfbf3b14..f9ab8299 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -11,7 +11,6 @@ import { beforeAll, describe, it, expect } from 'vitest' import { createPublicClient, createWalletClient, - recordActions, http, parseProgram, getContract, @@ -247,15 +246,20 @@ describe('Record inventory consumer example', () => { 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', tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } }, recordProvider: { requestRecords: async () => [{ programName: 'credits.aleo', recordName: 'credits', tag: '1field', spent: false, recordPlaintext: `{ owner: ${account.address}.private, microcredits: 4030000u64.private, _nonce: 1group.public }`, }] }, - }).extend(recordActions({ chainId: 'aleo:testnet', tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) + }) const plan = await client.planRecordInventory({ asset: { program: 'credits.aleo', standard: 'credits' }, 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/packages/cli/README.md b/packages/cli/README.md index a230ad17..ab61ec03 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -107,22 +107,29 @@ requires operator reconciliation; no force-unlock command is included. Only one manager can run for an account/chain against a shared journal. Pending trading operations take priority over maintenance. Participating trading clients -must open the same file and apply `recordActions` before `shieldSwapActions`: +must open the same file and explicitly enable coordination at client construction: ```ts -import { recordActions } from '@provablehq/veil-core' +import { loadNetwork } from '@provablehq/veil-aleo-sdk' import { sqliteRecordInventoryStore } from '@provablehq/veil-cli/storage' const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') -const managed = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet', - tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) -// Extend managed with shieldSwapActions(...) when trading. +const aleo = await loadNetwork('testnet') +const { walletClient } = aleo.createAleoClient({ + privateKey, // supplied by the application + recordManagement: { + enabled: true, store, chainId: 'aleo:testnet', + tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, + }, +}) +// Extend walletClient with shieldSwapActions(...) when trading. // Close the store when the application shuts down. ``` For the Shield Swap CLI, set `VEIL_INVENTORY_DB` to this same database path and select the same network. That opt-in integration also requires Node 22.13+. -Other SDK applications can implement `RecordInventoryStore` using their own +Record management is otherwise off, including on clients extended with +`recordActions()`. Other SDK applications can implement `RecordInventoryStore` using their own storage without depending on this package. Unmanaged wallets, raw transaction submission, and other database files cannot participate in these reservations. Managed writes use public fees or FeeMaster; private fee-record selection is diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0a9cf05e..481f411e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,6 @@ import { parseArgs } from 'node:util' import { readFile } from 'node:fs/promises' import { setTimeout as delay } from 'node:timers/promises' -import { recordActions } from '@provablehq/veil-core' import { loadNetwork } from '@provablehq/veil-aleo-sdk' import { sqliteRecordInventoryStore } from './storage.js' import { parseInventoryConfig } from './config.js' @@ -44,10 +43,12 @@ async function main(): Promise { const privateKey = process.env[config.privateKeyEnv] if (!privateKey) throw new Error(`Set ${config.privateKeyEnv} to the signing account private key`) const aleo = await loadNetwork(config.network) - const { walletClient, account } = aleo.createAleoClient({ privateKey, networkUrl: config.networkUrl, useFeeMaster: config.useFeeMaster, - records: aleo.createRemoteScanner({ waitForSync: true }) }) const store = await sqliteRecordInventoryStore(config.database) - const client = walletClient.extend(recordActions({ store, chainId: config.chainId, maxFeeMicrocredits: config.maxFeeMicrocredits, tokenJoin: config.tokenJoin })) + // This maintenance application explicitly enables its shared journal. Merely + // exposing record methods never opts other SDK clients into coordinated writes. + 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 } }) const scope = JSON.stringify([config.chainId, account.address]) let lease: string | undefined const controller = new AbortController() diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index f8c283f8..76b5bb3f 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -18,7 +18,7 @@ export type InventoryCycleResult = { program: string; status: string; result?: I * @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 recordActions. + * @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. diff --git a/packages/cli/src/storage.ts b/packages/cli/src/storage.ts index c7c373db..01e07e29 100644 --- a/packages/cli/src/storage.ts +++ b/packages/cli/src/storage.ts @@ -12,7 +12,10 @@ import type { RecordInventoryStore, RecordSpend } from '@provablehq/veil-core' * @throws On unsupported Node versions, corrupt data, non-regular files, or database errors. * @example * const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') - * const client = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) + * 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 diff --git a/packages/core/README.md b/packages/core/README.md index cf4395fb..8863e3cd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -248,13 +248,20 @@ const client = createPublicClient({ ## Record inventory -Extend a wallet client with `recordActions({ store?, chainId?, maxFeeMicrocredits?, tokenJoin? })` -for scanner-backed `joinRecords`, `splitRecord`, `autoJoin`, -`planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. Supports -ARC20, ARC22, and native credits. Core supplies a storage interface and in-memory -default; it has no SQLite or Node dependency. Configure -`tokenJoin: { program: 'main_aj_arc20_2_15.aleo' }` on mainnet (or -`test_aj_arc20_2_15.aleo` on testnet) to batch 2–15 token records per join -transaction while preserving reservations and recovery. Omission retains native -pairwise joins; credits are unaffected. See the +Wallet clients expose `getRecordInventory`, `joinRecords`, `splitRecord`, +`autoJoin`, `planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. +Reservations and recovery are off by default. Reads, plans, and individual joins +or splits do not require them. To coordinate writes and execute dependent plans, +pass `recordManagement: { enabled: true, store?, chainId?, maxFeeMicrocredits?, tokenJoin? }` +to `createWalletClient` or `createClient`. Enabling management creates an in-memory +store when no store is supplied; disabled clients allocate none. Core has no +SQLite or Node dependency. + +Configure `tokenJoin: { program: 'main_aj_arc20_2_15.aleo' }` on mainnet (or +`test_aj_arc20_2_15.aleo` on testnet) to batch 2–15 ARC20/ARC22 records per join. +Routing also works with `enabled: false`; omitted routing retains native +pairwise joins, and credits are unaffected. `recordActions()` remains an optional +method decorator for custom clients and never enables coordination. Move legacy +`recordActions({ store, maxFeeMicrocredits })` settings into explicitly enabled +client configuration to retain reservation and fee-limit behavior. See the [record inventory guide](../../site/docs/guides/record-inventory.md). diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index 4e0fc800..29b015df 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -1,6 +1,6 @@ import type { Client } from '../../clients/createClient.js' import type { InventoryResult, RecordAsset } from '../../inventory/types.js' -import { management, scopeOf } from '../../inventory/internal.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' @@ -18,7 +18,7 @@ export type AutoJoinParameters = Omit ({ 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 index 609555f2..a9eb86ba 100644 --- a/packages/core/src/actions/records/getRecordInventory.ts +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -21,7 +21,7 @@ export type GetRecordInventoryReturnType = { available: InventoryRecord[]; reser /** * 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, optionally extended with recordActions. + * @param client Account client; enabled record management excludes shared reservations. * @param params Token program and standard to inspect. * @returns Eligible records and their available balance; hits the network. * @throws On missing wallet grants, unsupported assets, or a non-terminating scan. diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index 7a6ee7fb..bb6c51e3 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -3,7 +3,7 @@ import type { InventoryRecord, RecordAsset } 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 } from '../../inventory/internal.js' +import { recordSettings } from '../../inventory/internal.js' import { validateTokenJoinFunction } from '../../inventory/tokenJoin.js' /** @@ -17,7 +17,7 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryReco /** * Joins records through a native transition or the configured token router. * Signs, proves and submits using the existing account and proving configuration. - * @param client Wallet client; recordActions enables shared reservations. + * @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. @@ -26,7 +26,7 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryReco */ export async function joinRecords(client: Client, params: JoinRecordsParameters): Promise { const capabilities = await validateAsset(client, params.asset) - const router = params.asset.standard !== 'credits' ? management(client)?.tokenJoin : undefined + const router = params.asset.standard !== 'credits' ? recordSettings(client).tokenJoin : undefined if (router) await validateTokenJoinFunction(client, router, params.records.length) else if (params.records.length !== 2) throw new Error('joinRecords requires two records without a token router') // Re-decode the supplied records before proving; cached inventory amounts are diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts index ffb33380..39fa75b8 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -1,6 +1,6 @@ import type { Client } from '../../clients/createClient.js' import type { InventoryPlan, InventoryTarget, RecordAsset } from '../../inventory/types.js' -import { management, scopeOf } from '../../inventory/internal.js' +import { recordSettings, scopeOf } from '../../inventory/internal.js' import { buildInventoryPlan } from '../../inventory/planner.js' import { getRecordInventory } from './getRecordInventory.js' @@ -26,6 +26,6 @@ export async function planRecordInventory(client: Client, params: PlanRecordInve const inventory = await getRecordInventory(client, { asset: params.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, tokenJoin: management(client)?.tokenJoin, + return buildInventoryPlan({ ...params, 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 index 68df2c6f..f0972d2c 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -29,7 +29,7 @@ export type RebalanceRecordInventoryParameters = { * 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 extended with recordActions for reservation and durable progress. + * @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 or missing coordination, before submission. @@ -38,7 +38,7 @@ export type RebalanceRecordInventoryParameters = { */ export async function rebalanceRecordInventory(client: Client, params: RebalanceRecordInventoryParameters): Promise { const config = management(client) - if (!config) throw new Error('Inventory execution requires recordActions') + 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) diff --git a/packages/core/src/actions/records/reconcileRecordInventory.ts b/packages/core/src/actions/records/reconcileRecordInventory.ts index 60fe6293..be0d0d49 100644 --- a/packages/core/src/actions/records/reconcileRecordInventory.ts +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -21,7 +21,7 @@ export type ReconcileRecordInventoryParameters = { rebroadcast?: boolean } */ export async function reconcileRecordInventory(client: Client, params: ReconcileRecordInventoryParameters = {}): Promise { const config = management(client) - if (!config) throw new Error('Reconciliation requires recordActions with a shared store') + 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 diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts index c8f4735e..d9e7840e 100644 --- a/packages/core/src/actions/records/splitRecord.ts +++ b/packages/core/src/actions/records/splitRecord.ts @@ -16,7 +16,7 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor /** * 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; recordActions enables shared reservations. + * @param client Wallet client; recordManagement.enabled opts into shared reservations. * @param params Source record and positive first output amount (u64 credits, u128 tokens). * @returns Submitted transaction id, before confirmation. * @throws When either output would be empty or inputs are incompatible. diff --git a/packages/core/src/clients/createClient.ts b/packages/core/src/clients/createClient.ts index 17a542f5..281d07d7 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,7 @@ 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 coordination; omitted or enabled: false leaves writes unchanged. * @property transport Transport that carries requests to the network. */ export type ClientConfig = { @@ -20,6 +22,7 @@ export type ClientConfig = { key?: string | undefined name?: string | undefined proving?: ProvingConfig | undefined + recordManagement?: RecordManagementConfig | undefined transport: Transport } @@ -35,6 +38,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 Explicit record coordination configuration, 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 +48,7 @@ type ClientBase = { key: string name: string proving: ProvingConfig | undefined + recordManagement?: RecordManagementConfig | undefined request: Transport['request'] transport: Transport uid: string @@ -124,6 +129,7 @@ export function createClient(config: ClientConfig): Client { key = 'base', name = 'Client', proving, + recordManagement, transport, } = config @@ -134,6 +140,8 @@ export function createClient(config: ClientConfig): Client { key, name, proving, + // Bind normalized opt-in before decorators capture the base 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..4e3eba5c 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 record coordination. 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 record coordination. 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..cdeff932 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 { + // Record methods are always available; reservations require constructor opt-in. + ...recordActionMethods(client), sendTransaction: (params) => sendTransaction(client, params), writeContract: (params) => writeContract(client, params), executeTransaction: (params) => writeContract(client, params), diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts index 50425ac3..2ad0a353 100644 --- a/packages/core/src/inventory/internal.ts +++ b/packages/core/src/inventory/internal.ts @@ -1,11 +1,14 @@ import type { Client } from '../clients/createClient.js' import type { OwnedRecordEncrypted, OwnedRecord } from '../types/records.js' import { parseRecord } from '../utils/records.js' -import type { RecordActionsConfig, RecordInventoryStore } from './types.js' -import { holdsRecords } from './store.js' +import type { RecordActionsConfig, RecordManagementConfig, RecordInventoryStore } from './types.js' +import { holdsRecords, memoryRecordInventoryStore } from './store.js' +import { validateTokenJoinRouter } from './tokenJoin.js' +import type { Transport } from '../types/transport.js' /** - * Carries the coordination context attached by recordActions. + * Carries the enabled coordination context resolved during client construction. + * @property enabled Confirms that the caller explicitly opted into coordination. * @property chainId Stable chain identity for reservation scopes. * @property store Shared atomic journal. * @property maxFeeMicrocredits Optional proved fee ceiling; absent means no limit. @@ -14,24 +17,56 @@ import { holdsRecords } from './store.js' * @internal */ export type RecordManagement = Required> & { + enabled: true store: RecordInventoryStore maxFeeMicrocredits?: bigint tokenJoin?: RecordActionsConfig['tokenJoin'] network: string | null | undefined } -/** Reads coordination settings and rejects a transport that switched networks. @internal */ +/** + * Resolves opt-in settings before any wallet action captures the base client. + * Disabled configuration never allocates a store or validates 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 as Client & { recordManagement?: RecordManagement }).recordManagement - if (config && config.network !== client.transport.config.network) { - throw new Error('Record management network changed; rebuild recordActions for the new chain') + 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 { + // Constructor settings take precedence over compatibility-decorator options. + // A decorator must never redirect the journal namespace of managed writes. + 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 ?? client.transport.config.network + 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]) } @@ -53,7 +88,7 @@ export function inventoryRecordId(record: OwnedRecordEncrypted): string { /** * Lists nonces held by pending or confirmed operations, including stale scanner inputs. - * @param client Client extended with recordActions; returns an empty set otherwise. + * @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) @@ -61,6 +96,7 @@ export function inventoryRecordId(record: OwnedRecordEncrypted): string { 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))).filter(holdsRecords).flatMap((entry) => entry.records)) } diff --git a/packages/core/src/inventory/recordActions.ts b/packages/core/src/inventory/recordActions.ts index 4a80a287..e693009a 100644 --- a/packages/core/src/inventory/recordActions.ts +++ b/packages/core/src/inventory/recordActions.ts @@ -1,7 +1,5 @@ import type { Client } from '../clients/createClient.js' import type { RecordActionsConfig } from './types.js' -import type { RecordManagement } from './internal.js' -import { memoryRecordInventoryStore } from './store.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' @@ -9,41 +7,43 @@ import { reconcileRecordInventory, type ReconcileRecordInventoryParameters } fro 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 { writeContract, type WriteContractParameters } from '../actions/wallet/writeContract.js' -import { executeContract, type ExecuteContractParameters } from '../actions/wallet/executeContract.js' import { validateTokenJoinRouter } from './tokenJoin.js' /** - * Adds record inventory actions and shared spending coordination to an account client. - * Configuration is opt-in; apply before protocol decorators such as shieldSwapActions. - * @param config Shared store, chain identity, and optional per-transaction fee limit. - * @returns An extend decorator; construction performs no network calls. - * @throws When chain identity is absent or the fee limit is negative. + * Binds account-dependent record methods without changing spending configuration. + * Shared by walletActions and the optional compatibility decorator. + * @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 record methods to a custom client without enabling reservation or recovery. + * Wallet clients already expose these methods. Configure recordManagement with + * enabled: true at client construction to coordinate ordinary writes and execute + * inventory plans. Legacy store and maxFeeMicrocredits options here are unused. + * @param config Optional chain identity and token join router for record actions. + * Omission uses the transport network and native joins. Constructor settings take precedence. + * @returns An extend decorator; construction performs no network calls and allocates no journal. + * @throws When the optional token join router is invalid. * @example - * const client = walletClient.extend(recordActions({ store, chainId: 'aleo:testnet' })) + * const client = walletClient.extend(recordActions({ tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } })) */ export function recordActions(config: RecordActionsConfig = {}) { - const store = config.store ?? memoryRecordInventoryStore() + // Keep only action-routing options: exposing methods must never opt into writes. + const recordActionConfig = { chainId: config.chainId, tokenJoin: config.tokenJoin && { ...config.tokenJoin } } + if (recordActionConfig.tokenJoin) validateTokenJoinRouter(recordActionConfig.tokenJoin) return (client: Client) => { - const chainId = config.chainId ?? client.transport.config.network - if (!chainId) throw new Error('recordActions requires chainId on a transport without a network') - if (config.maxFeeMicrocredits !== undefined && config.maxFeeMicrocredits < 0n) throw new Error('Fee limit cannot be negative') - const tokenJoin = config.tokenJoin && { ...config.tokenJoin } - if (tokenJoin) validateTokenJoinRouter(tokenJoin) - const recordManagement: RecordManagement = { store, chainId, network: client.transport.config.network, maxFeeMicrocredits: config.maxFeeMicrocredits, tokenJoin } - const scoped = Object.assign(Object.create(client), { recordManagement }) as Client - return { - recordManagement, - getRecordInventory: (params: GetRecordInventoryParameters) => getRecordInventory(scoped, params), - planRecordInventory: (params: PlanRecordInventoryParameters) => planRecordInventory(scoped, params), - rebalanceRecordInventory: (params: RebalanceRecordInventoryParameters) => rebalanceRecordInventory(scoped, params), - reconcileRecordInventory: (params?: ReconcileRecordInventoryParameters) => reconcileRecordInventory(scoped, params), - joinRecords: (params: JoinRecordsParameters) => joinRecords(scoped, params), - splitRecord: (params: SplitRecordParameters) => splitRecord(scoped, params), - autoJoin: (params: AutoJoinParameters) => autoJoin(scoped, params), - writeContract: (params: WriteContractParameters) => writeContract(scoped, params), - executeTransaction: (params: WriteContractParameters) => writeContract(scoped, params), - executeContract: (params: ExecuteContractParameters) => executeContract(scoped, params), - } + const scoped = Object.assign(Object.create(client), { recordActionConfig }) as Client + return { recordActionConfig, ...recordActionMethods(scoped) } } } diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts index 2919cff1..7408227f 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -130,12 +130,13 @@ export type RecordInventoryStore = { } /** - * Configures opt-in coordination for inventory and ordinary writes. - * @property store Shared reservation/journal store. Defaults to an in-memory store per decorator. - * @property chainId Stable chain identity shared by all participants. Defaults to transport network; - * custom/local chains MUST supply a distinct identity. - * @property maxFeeMicrocredits Maximum proved transaction fee, before broadcast. Optional, no limit by default. - * @property tokenJoin Optional dynamic router for ARC20/ARC22 joins. Defaults to native pairwise joins; credits are unaffected. + * Configures record action routing without enabling spending coordination. + * @property chainId Optional chain identity for plans. Defaults to the transport network. + * @property tokenJoin Optional dynamic router for ARC20/ARC22 joins. Defaults to native pairwise joins. + * @property store Legacy option accepted for compatibility but unused by recordActions. + * Configure recordManagement with enabled: true on the client to share a journal. + * @property maxFeeMicrocredits Legacy option accepted for compatibility but unused by recordActions. + * Configure recordManagement with enabled: true on the client to enforce a fee ceiling. */ export type RecordActionsConfig = { store?: RecordInventoryStore @@ -144,6 +145,25 @@ export type RecordActionsConfig = { tokenJoin?: TokenJoinRouter } +/** + * Enables shared record reservations and transaction recovery on a client. + * Management is off unless enabled is true; merely exposing record actions does + * not allocate a journal or change ordinary writes. Disabled settings may still + * supply a chain identity and token router for explicit record actions. + * @property enabled Explicit opt-in for reservation and recovery. Defaults to false when omitted from client configuration. + * @property store Shared atomic journal. 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 proved transaction fee in microcredits (u64), before broadcast. + * Optional, with no limit by default; ignored when disabled. + * @property tokenJoin Optional ARC20/ARC22 join router. Defaults to native pairwise joins; credits are unaffected. + * @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. diff --git a/packages/core/test/inventory/configuration.test.ts b/packages/core/test/inventory/configuration.test.ts new file mode 100644 index 00000000..eed5bb40 --- /dev/null +++ b/packages/core/test/inventory/configuration.test.ts @@ -0,0 +1,111 @@ +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('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('public fees') + 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 index 4ad46846..48a12867 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -69,7 +69,7 @@ function world(amounts: bigint[], asset = credits, tokenJoin?: TokenJoinRouter) 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() {} } }).extend(recordActions({ store, tokenJoin })) + recordProvider: { requestRecords: scan, setAccount() {} }, recordManagement: { enabled: true, store, tokenJoin } }) return { client, store, build, send, scan, transactions, records, setRecords: (value: OwnedRecord[]) => { records = value } } } @@ -136,7 +136,7 @@ describe('scanner-backed inventory', () => { throw new Error(method) }) const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, - transport: { ...w.client.transport, request } as any }).extend(recordActions({ store: w.store })) + 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 }) @@ -162,7 +162,7 @@ describe('inventory execution and recovery', () => { })) 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 }).extend(recordActions({ store: w.store })) + 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({ @@ -203,7 +203,7 @@ describe('inventory execution and recovery', () => { throw new Error(method) }) const client = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, - transport: { ...w.client.transport, request } as any }).extend(recordActions({ store: w.store })) + 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'], @@ -219,7 +219,7 @@ describe('inventory execution and recovery', () => { })) 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 }).extend(recordActions({ store: w.store })) + 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: [] }) @@ -350,7 +350,7 @@ describe('configured token batch joins', () => { throw new Error(method) }) const wallet = createWalletClient({ account: { ...w.client.account!, type: 'rpc' } as any, - transport: { ...w.client.transport, request } as any }).extend(recordActions({ store: w.store, tokenJoin: router })) + 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], diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index 22d28d07..b221ca2b 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -163,8 +163,12 @@ resolves and caches witnesses automatically for local and delegated proving. ## Inventory scanning and proving -Use `createRemoteScanner({ waitForSync: true })` with core's `recordActions` for -inventory maintenance. Synchronization polling uses the existing SDK scanner; +Wallet clients already expose inventory methods. Pass +`recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }` to +`createAleoClient` to opt into shared reservations and recovery. The same option +is accepted by `createDevnodeClient`; a local chain should use a distinct +`chainId`. Omission or `enabled: false` leaves ordinary writes unchanged. +Use `createRemoteScanner({ waitForSync: true })` for inventory maintenance. Synchronization polling uses the existing SDK scanner; `syncTimeoutMs` defaults to 300000 milliseconds and `waitForSync` defaults to false for existing consumers. Both local and delegated execution honor an awaited `transaction-prepared` callback before submission, allowing an application to diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index 91cd538d..b51277e9 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, 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' @@ -410,6 +410,8 @@ 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; enabled: true opts in before wallet actions are bound. * @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. @@ -451,6 +453,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' @@ -677,6 +680,8 @@ 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 } + // Dynamic token programs can import other programs themselves; include + // those transitive sources so routed joins can be proved completely. for (const name of txOptions.imports) { const dynamicSource = await programManager.networkClient.getProgram(name) Object.assign(merged, await programManager.networkClient.getProgramImports(dynamicSource)) @@ -872,6 +877,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? false, + // An awaited checkpoint requires control of broadcast to stay with + // this client until the proved transaction has been saved. broadcast: !execOptions.onProgress, useFeeMaster: options.useFeeMaster ?? false, }) @@ -961,6 +968,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw new ProvingError({ message: e instanceof Error ? e.message : String(e), cause: e as Error }) } + // Convert the WASM transaction into the serializable value stored by + // recovery before the network can observe this execution. const prepared = JSON.parse(tx.toString()) as Transaction await execOptions.onProgress?.({ type: 'transaction-prepared', transactionId: prepared.id, transaction: prepared }) let txId: string @@ -1279,11 +1288,14 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo 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 the SDK's owned() auto-registration recovery. + // 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})`) @@ -1367,6 +1379,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } function createAleoClient(options: { + recordManagement?: RecordManagementConfig privateKey: string networkUrl?: string provingMode?: 'delegated' | 'local' @@ -1473,6 +1486,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo transport, proving, recordProvider: records, + // Forward opt-in before wallet methods close over their client. + recordManagement: options.recordManagement, }).extend(provableApiActions()) return { publicClient, walletClient, account } @@ -1556,6 +1571,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 @@ -1569,6 +1586,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 @@ -1669,6 +1687,8 @@ export function createDevnodeClient(options?: { imports: execOptions.programImports, }) + // Preserve the same prepare-before-broadcast checkpoint used by hosted + // execution so recovery behavior also holds against the local devnode. const prepared = JSON.parse(tx.toString()) as Transaction await execOptions.onProgress?.({ type: 'transaction-prepared', transactionId: prepared.id, transaction: prepared }) @@ -1701,7 +1721,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/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/shield-swap-cli/README.md b/packages/shield-swap-cli/README.md index 0c0ded09..18fdc0df 100644 --- a/packages/shield-swap-cli/README.md +++ b/packages/shield-swap-cli/README.md @@ -236,8 +236,10 @@ position's withdrawal address as the proof subject. ## Share record reservations with inventory maintenance Set `VEIL_INVENTORY_DB` to the same journal path used by `veil inventory run`. -The session applies core's `recordActions` before Shield Swap actions so trading -and maintenance cannot reserve the same explicit record inputs. This optional +The session sets `recordManagement: { enabled: true, store, chainId }` during +client construction so trading and maintenance cannot reserve the same explicit +record inputs. This optional CLI integration loads `@provablehq/veil-cli/storage` and requires Node 22.13+. -Without the environment variable, the existing runtime path is unchanged. +Without the environment variable, record coordination remains disabled and no +inventory journal is opened. Exposing record methods does not enable it. See the [Veil CLI reference](../cli/README.md) for policy configuration and recovery. diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index 2455a6af..34956cd6 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -23,7 +23,6 @@ * version control (`.shield-swap/` belongs in .gitignore) and treat it like * a wallet file. */ -import { recordActions } from '@provablehq/veil-core' import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, renameSync } from 'node:fs' import { join } from 'node:path' import { @@ -243,11 +242,21 @@ 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('@provablehq/veil-cli/storage')).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, + // Constructor opt-in reaches both ordinary writes and Shield Swap actions. + recordManagement: inventoryStore ? { + enabled: true, store: inventoryStore, chainId: `aleo:${network}`, + tokenJoin: { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' }, + } : 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. @@ -261,15 +270,9 @@ export async function loadSession(options: { network?: string } = {}) { // meaningful against the chain it was checked on. Every swap through this // client reserves and records automatically. const blindedIdentities = fileBlindedIdentityStore(blindedStorePath(network)) - // Opt in to the same durable record journal as `veil inventory run`. - const inventoryPath = process.env.VEIL_INVENTORY_DB - const coordinated = inventoryPath - ? walletClient.extend(recordActions({ store: await (await import('@provablehq/veil-cli/storage')).sqliteRecordInventoryStore(inventoryPath), chainId: `aleo:${network}`, - tokenJoin: { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' } })) - : walletClient // SDK proving supplies automatic freezelist witnesses for every write command; // shieldSwapActions checks current roots and caches witnesses for this session. - const client = coordinated.extend( + const client = walletClient.extend( shieldSwapActions({ api: { baseUrl: apiUrl }, blindedIdentities }), ) try { diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index f854bab5..41b3af53 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -1350,13 +1350,16 @@ for deployments that accept them. Omission now checks the actual current list. ## Record inventory -Configure `recordActions({ tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } })` -before the DEX decorator to batch underlying ARC20/ARC22 joins on mainnet. Use +Configure `recordManagement.tokenJoin` with +`{ program: 'main_aj_arc20_2_15.aleo' }` at client construction to batch +underlying ARC20/ARC22 joins on mainnet. Use `test_aj_arc20_2_15.aleo` on testnet. The DEX inventory wrappers preserve this configuration, target counts, shared reservations, and exact-output recovery. -Apply core's `recordActions` before `shieldSwapActions` to coordinate maintenance -with trading. `planInventory({ token, target })` and +Reservations and recovery are off by default. Set +`recordManagement: { enabled: true, store, chainId }` when constructing the +wallet client, then apply `shieldSwapActions` to coordinate maintenance with +trading. Adding `recordActions()` alone never enables coordination. `planInventory({ token, target })` and `rebalanceInventory({ token, target })` resolve the DEX token's underlying program and delegate to core's ARC20/ARC22/credits helpers. Participating clients must share the same store and chain identity. No database dependency is added to the diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts index 08bdaec4..b0af9654 100644 --- a/packages/shield-swap/src/actions/inventory/planInventory.ts +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -15,7 +15,7 @@ export type PlanInventoryParameters = { token: string; target: InventoryTarget; /** * 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, optionally extended with recordActions. + * @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. diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts index 8c2a680f..9047c0c8 100644 --- a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -7,7 +7,7 @@ export type RebalanceInventoryParameters = PlanInventoryParameters & Omit { 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 = Object.assign(base, recordActions({ store })(base)) + 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 () => { diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 3e19fe58..2641a05a 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -4,21 +4,27 @@ A private token balance can span many records. A swap may need one record large enough to spend, while concurrent trades benefit from several independent records. Inventory actions join and split existing records to shape that balance. -## Add inventory actions +## Enable record coordination + +Wallet clients already expose record inventory methods. Reservations and recovery +are **off by default**, including after `.extend(recordActions())`. Reads, +planning, and individual joins or splits remain available without coordination. +Set `recordManagement.enabled: true` when constructing a client to coordinate +writes, execute inventory plans, and recover interrupted operations. ```ts -import { recordActions } from '@provablehq/veil-core' import { loadNetwork } from '@provablehq/veil-aleo-sdk' const aleo = await loadNetwork('testnet') -const { walletClient } = aleo.createAleoClient({ +const { walletClient: client } = aleo.createAleoClient({ privateKey, // supplied by the application records: aleo.createRemoteScanner({ waitForSync: true }), + recordManagement: { + enabled: true, + chainId: 'aleo:testnet', + tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, + }, }) -const client = walletClient.extend(recordActions({ - chainId: 'aleo:testnet', - tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, -})) const asset = { program: 'credits.aleo', standard: 'credits' } as const const plan = await client.planRecordInventory({ asset, @@ -33,8 +39,9 @@ const result = await client.rebalanceRecordInventory({ Core provides runtime-independent planning, execution, and storage interfaces. The Aleo SDK supplies the existing record scanner and local/delegated proving. -The default reservation store is in memory. Long-running services MUST supply -an appropriate durable `RecordInventoryStore`; SQLite is available exclusively +An enabled client defaults to an in-memory reservation store; an omitted or +disabled configuration allocates none. Long-running services MUST supply +an appropriate durable `RecordInventoryStore` through `recordManagement.store`; SQLite is available exclusively from the separate `@provablehq/veil-cli/storage` application package. | Action | Behavior | @@ -42,10 +49,10 @@ from the separate `@provablehq/veil-cli/storage` application package. | `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | | `joinRecords({ asset, records })` | Joins two native records, or 2–15 token records with a configured router; returns the transaction id. | | `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | -| `autoJoin({ asset, minAmount? })` | Consolidates enough records to cover an amount, or all records when omitted. | +| `autoJoin({ asset, minAmount? })` | Consolidates enough records to cover an amount, or all records when omitted; execution requires enabled management. | | `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | -| `rebalanceRecordInventory({ plan, ... })` | Executes sequentially and waits for confirmed scanner outputs. | -| `reconcileRecordInventory({ rebroadcast? })` | Resolves pending journal entries; optional exact-transaction rebroadcast. | +| `rebalanceRecordInventory({ plan, ... })` | Requires enabled management; executes sequentially and waits for confirmed scanner outputs. | +| `reconcileRecordInventory({ rebroadcast? })` | Requires enabled management; resolves pending journal entries with optional exact-transaction rebroadcast. | Assets use `standard: 'credits'`, `'arc20'`, or `'arc22'`. Credits MUST use `credits.aleo`. ARC tokens are checked against their deployed Token and join/split @@ -64,11 +71,14 @@ width. `maxTransactions` defaults to 100. ### Batch token consolidation -Configure `recordActions({ tokenJoin: { program, maxRecords? } })` to route ARC20 +Configure `recordManagement.tokenJoin: { program, maxRecords? }` to route ARC20 and ARC22 joins through a dedicated dynamic join program. Mainnet uses `main_aj_arc20_2_15.aleo`; testnet uses `test_aj_arc20_2_15.aleo`. `maxRecords` defaults to 15 and accepts 2–15. Core has no network-specific router default; omitting `tokenJoin` preserves native pairwise joins. Credits remain native. +Routing also works with `enabled: false` for planning or individual joins; it +does not enable reservations. The optional `recordActions({ tokenJoin })` +decorator remains available for custom clients without constructor settings. The planner batches only as many inputs as the target count requires and bounds each summed amount to u128. Sixteen token records consolidate through `join_15` @@ -81,7 +91,7 @@ Each router call prepends the underlying program's identifier (for example, resolves its transitive imports for both local and delegated proving. Wallet adapters must support dynamic calls and pinned underlying-token records. -All batch inputs are reserved atomically. Fees, durable proof checkpoints, and +With management enabled, all batch inputs are reserved atomically. Fees, durable proof checkpoints, and exact-transaction recovery use the same inventory journal. Confirmed router outputs carry dynamic ids; execution resolves each id to the matching nested token join's record commitment before waiting for the scanner. Intermediate @@ -97,7 +107,8 @@ const result = await dex.rebalanceInventory({ token: 'USDCx', target: { records: Import `shieldSwapActions` from `@provablehq/shield-swap-sdk`. The wrapper resolves token metadata to `underlyingProgram`, then uses the same core actions. -Apply `recordActions` first. Token selection skips shared reservations and +Construct the wallet with `recordManagement: { enabled: true, store, chainId }` +before applying `shieldSwapActions`. Token selection then skips shared reservations and `writeContract`/`executeTransaction`/`executeContract` reserve explicit record inputs atomically before proving. Independent clients MUST share the same store and chain identity. This coordination does not cover raw submissions or other @@ -134,3 +145,16 @@ selected outside this reservation mechanism. Use public fees or FeeMaster. For a supervised background process with policies, cooldowns, budgets, and a restart journal, use the [inventory CLI](../packages/cli.md). + +## Migrate a recordActions configuration + +Move `store`, `chainId`, `maxFeeMicrocredits`, and `tokenJoin` into the client's +`recordManagement` configuration and add `enabled: true` to retain coordination. +The same option is accepted by `createClient`, `createWalletClient`, +`aleo.createAleoClient`, and `createDevnodeClient`. Wallet clients need no +`recordActions` extension to expose the methods. + +`recordActions({ store, maxFeeMicrocredits })` alone does not reserve records, +limit fees, or enable recovery. These legacy options remain accepted but are +unused. Its `chainId` and `tokenJoin` options still configure record actions when +constructor settings are absent; constructor settings take precedence. From 7bdfade390283b9d5d8a99f70508cfc8b79da707 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 10:34:21 -0400 Subject: [PATCH 07/32] [Docs] Clarify record inventory configuration and recovery --- README.md | 14 ++- packages/cli/README.md | 23 ++--- packages/cli/src/index.ts | 4 +- packages/cli/src/runner.ts | 4 +- packages/core/README.md | 31 +++--- .../src/actions/wallet/executeContract.ts | 8 +- .../core/src/actions/wallet/writeContract.ts | 4 +- packages/core/src/clients/createClient.ts | 7 +- .../core/src/clients/createWalletClient.ts | 4 +- .../core/src/clients/decorators/wallet.ts | 2 +- packages/core/src/inventory/internal.ts | 12 +-- packages/core/src/inventory/recordActions.ts | 23 +++-- packages/core/src/inventory/types.ts | 27 +++--- packages/provable-sdk/README.md | 24 ++--- packages/provable-sdk/src/index.ts | 21 ++-- packages/shield-swap-cli/README.md | 17 ++-- packages/shield-swap-cli/src/session.ts | 2 +- packages/shield-swap/README.md | 26 ++--- .../actions/inventory/rebalanceInventory.ts | 4 +- site/docs/guides/record-inventory.md | 97 +++++++++++-------- 20 files changed, 196 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 00759261..2976925c 100644 --- a/README.md +++ b/README.md @@ -248,11 +248,15 @@ const tools = aleoAgentTools({ ## Record inventory management -Core provides scanner-backed join, split, and inventory-rebalance actions for -ARC20, ARC22, and native credits. Shield Swap resolves DEX tokens to their -underlying programs. The separate [`@provablehq/veil-cli`](packages/cli/README.md) -application supplies background maintenance and a CLI-only SQLite journal. -Start with the [inventory guide](site/docs/guides/record-inventory.md). +Core reads records from a scanner and joins or splits them to prepare an +ARC20, ARC22, or native credits balance for spending. Shield Swap applies these +operations to each DEX token's underlying program. The separate +[`@provablehq/veil-cli`](packages/cli/README.md) runs background maintenance and +stores recovery data in SQLite. + +Record reservations and recovery are off by default. Follow the +[record inventory guide](site/docs/guides/record-inventory.md) to enable them, +configure storage, and coordinate maintenance with trading. ## License diff --git a/packages/cli/README.md b/packages/cli/README.md index ab61ec03..ac76f95d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -105,9 +105,9 @@ 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. -Only one manager can run for an account/chain against a shared journal. Pending -trading operations take priority over maintenance. Participating trading clients -must open the same file and explicitly enable coordination at client construction: +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' @@ -126,14 +126,15 @@ const { walletClient } = aleo.createAleoClient({ // Close the store when the application shuts down. ``` -For the Shield Swap CLI, set `VEIL_INVENTORY_DB` to this same database path and -select the same network. That opt-in integration also requires Node 22.13+. -Record management is otherwise off, including on clients extended with -`recordActions()`. Other SDK applications can implement `RecordInventoryStore` using their own -storage without depending on this package. Unmanaged wallets, raw transaction -submission, and other database files cannot participate in these reservations. -Managed writes use public fees or FeeMaster; private fee-record selection is -not coordinated and is rejected. +For the Shield Swap CLI, set `VEIL_INVENTORY_DB` to this database path and +select the same network. This option requires Node 22.13+. Record management is +off by default, including on 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 writes +require public fees or FeeMaster because private fee selection can spend records +outside the shared reservations. SIGINT/SIGTERM stops new work after the current operation returns. Proving or an in-flight request may delay shutdown; durable checkpoints survive forced diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 481f411e..805291d6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -44,8 +44,8 @@ async function main(): Promise { if (!privateKey) throw new Error(`Set ${config.privateKeyEnv} to the signing account private key`) const aleo = await loadNetwork(config.network) const store = await sqliteRecordInventoryStore(config.database) - // This maintenance application explicitly enables its shared journal. Merely - // exposing record methods never opts other SDK clients into coordinated writes. + // Enable the shared journal so maintenance and participating trading clients + // cannot reserve the same records. Execution still requires --execute. 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 } }) diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 76b5bb3f..daa08958 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -51,8 +51,8 @@ export async function runInventoryCycle(client: Client, config: InventoryConfig, } try { const inventory = await getRecordInventory(client, { asset: policy.asset }) - // A count range deliberately tolerates inventory drift; preserve usable - // records instead of repeatedly reshaping them to the exact target. + // 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))) { diff --git a/packages/core/README.md b/packages/core/README.md index 8863e3cd..694dd189 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -250,18 +250,23 @@ const client = createPublicClient({ Wallet clients expose `getRecordInventory`, `joinRecords`, `splitRecord`, `autoJoin`, `planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. -Reservations and recovery are off by default. Reads, plans, and individual joins -or splits do not require them. To coordinate writes and execute dependent plans, -pass `recordManagement: { enabled: true, store?, chainId?, maxFeeMicrocredits?, tokenJoin? }` -to `createWalletClient` or `createClient`. Enabling management creates an in-memory -store when no store is supplied; disabled clients allocate none. Core has no -SQLite or Node dependency. +Reservations keep participating clients from spending the same record; +recovery tracks unfinished transactions. Both are off by default. Reads, plans, +and individual joins or splits remain available while they are disabled. + +To reserve records during writes and execute plans with dependent steps, pass +`recordManagement: { enabled: true, store?, chainId?, maxFeeMicrocredits?, tokenJoin? }` +to `createWalletClient` or `createClient`. An enabled client creates an in-memory +store if none is supplied. Disabled clients create no store. Core has no SQLite +or Node dependency. Configure `tokenJoin: { program: 'main_aj_arc20_2_15.aleo' }` on mainnet (or -`test_aj_arc20_2_15.aleo` on testnet) to batch 2–15 ARC20/ARC22 records per join. -Routing also works with `enabled: false`; omitted routing retains native -pairwise joins, and credits are unaffected. `recordActions()` remains an optional -method decorator for custom clients and never enables coordination. Move legacy -`recordActions({ store, maxFeeMicrocredits })` settings into explicitly enabled -client configuration to retain reservation and fee-limit behavior. See the -[record inventory guide](../../site/docs/guides/record-inventory.md). +`test_aj_arc20_2_15.aleo` on testnet) to join 2 to 15 ARC20/ARC22 records in one +transaction. Routing also works with `enabled: false`. Without a router, token +joins combine two records at a time. Credits always use native joins. + +`recordActions()` adds these methods to custom clients without enabling +reservations or recovery. Move `recordActions({ store, maxFeeMicrocredits })` +settings into the enabled client configuration to reserve records and enforce +fee limits. The [record inventory guide](../../site/docs/guides/record-inventory.md) +covers configuration, shared storage, and recovery after an interruption. diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index 2cc588de..c1d4eac4 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -81,8 +81,8 @@ export async function executeContract( params: ExecuteContractParameters, ): Promise { if (management(client)) { - // Copy the inputs before asynchronous reservation checks, then run the usual - // execution path with journaling callbacks and without acquiring twice. + // 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) => executeContract(withoutManagement(client), { ...snapshot, onProgress })) @@ -124,8 +124,8 @@ export async function executeContract( // Local proving resolves no wallet-side inputs — only encoded strings. assertNoInputRequests(params.inputs) - // The prover owns build, broadcast, and confirmation on this path; forward - // the awaited callback so it can checkpoint before crossing each boundary. + // 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, diff --git a/packages/core/src/actions/wallet/writeContract.ts b/packages/core/src/actions/wallet/writeContract.ts index 41d6492a..fcc59abc 100644 --- a/packages/core/src/actions/wallet/writeContract.ts +++ b/packages/core/src/actions/wallet/writeContract.ts @@ -70,8 +70,8 @@ export async function writeContract( params: WriteContractParameters, ): Promise { if (management(client)) { - // Copy the inputs before asynchronous reservation checks, then run the usual - // execution path with journaling callbacks and without acquiring twice. + // 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) => writeContract(withoutManagement(client), { ...snapshot, onProgress })) diff --git a/packages/core/src/clients/createClient.ts b/packages/core/src/clients/createClient.ts index 281d07d7..f36fba2a 100644 --- a/packages/core/src/clients/createClient.ts +++ b/packages/core/src/clients/createClient.ts @@ -14,7 +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 coordination; omitted or enabled: false leaves writes unchanged. + * @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 = { @@ -38,7 +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 Explicit record coordination configuration, or undefined when omitted. + * @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. @@ -140,7 +141,7 @@ export function createClient(config: ClientConfig): Client { key, name, proving, - // Bind normalized opt-in before decorators capture the base client. + // Resolve record settings before action closures capture the client. recordManagement: configureRecordManagement(recordManagement, transport), request: transport.request, transport, diff --git a/packages/core/src/clients/createWalletClient.ts b/packages/core/src/clients/createWalletClient.ts index 4e3eba5c..748b7e53 100644 --- a/packages/core/src/clients/createWalletClient.ts +++ b/packages/core/src/clients/createWalletClient.ts @@ -10,7 +10,7 @@ import { walletActions, type WalletActions } from './decorators/wallet.js' * 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 record coordination. Defaults to disabled. + * @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. */ @@ -29,7 +29,7 @@ export type RpcWalletClientConfig = { * @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 record coordination. Defaults to disabled. + * @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. */ diff --git a/packages/core/src/clients/decorators/wallet.ts b/packages/core/src/clients/decorators/wallet.ts index cdeff932..5fabcc1c 100644 --- a/packages/core/src/clients/decorators/wallet.ts +++ b/packages/core/src/clients/decorators/wallet.ts @@ -69,7 +69,7 @@ export type WalletActions = ReturnType & { */ export function walletActions(client: Client): WalletActions { return { - // Record methods are always available; reservations require constructor opt-in. + // Include record methods on every wallet; client configuration controls reservations. ...recordActionMethods(client), sendTransaction: (params) => sendTransaction(client, params), writeContract: (params) => writeContract(client, params), diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts index 2ad0a353..b84a4ab3 100644 --- a/packages/core/src/inventory/internal.ts +++ b/packages/core/src/inventory/internal.ts @@ -7,8 +7,8 @@ import { validateTokenJoinRouter } from './tokenJoin.js' import type { Transport } from '../types/transport.js' /** - * Carries the enabled coordination context resolved during client construction. - * @property enabled Confirms that the caller explicitly opted into coordination. + * 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. @@ -25,8 +25,8 @@ export type RecordManagement = Required> & } /** - * Resolves opt-in settings before any wallet action captures the base client. - * Disabled configuration never allocates a store or validates write restrictions. + * 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 { @@ -57,8 +57,8 @@ export function management(client: Client): RecordManagement | undefined { /** Reads routing settings independently of whether spending coordination is enabled. @internal */ export function recordSettings(client: Client): Pick { - // Constructor settings take precedence over compatibility-decorator options. - // A decorator must never redirect the journal namespace of managed writes. + // 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 diff --git a/packages/core/src/inventory/recordActions.ts b/packages/core/src/inventory/recordActions.ts index e693009a..9297e031 100644 --- a/packages/core/src/inventory/recordActions.ts +++ b/packages/core/src/inventory/recordActions.ts @@ -10,8 +10,8 @@ import { autoJoin, type AutoJoinParameters } from '../actions/records/autoJoin.j import { validateTokenJoinRouter } from './tokenJoin.js' /** - * Binds account-dependent record methods without changing spending configuration. - * Shared by walletActions and the optional compatibility decorator. + * Binds record methods to an account client without changing its spending settings. + * Used by walletActions and recordActions. * @internal */ export function recordActionMethods(client: Client) { @@ -27,19 +27,22 @@ export function recordActionMethods(client: Client) { } /** - * Adds record methods to a custom client without enabling reservation or recovery. - * Wallet clients already expose these methods. Configure recordManagement with - * enabled: true at client construction to coordinate ordinary writes and execute - * inventory plans. Legacy store and maxFeeMicrocredits options here are unused. - * @param config Optional chain identity and token join router for record actions. - * Omission uses the transport network and native joins. Constructor settings take precedence. - * @returns An extend decorator; construction performs no network calls and allocates no journal. + * 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 only action-routing options: exposing methods must never opt into writes. + // 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) => { diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts index 7408227f..f1e99a6d 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -133,10 +133,10 @@ export type RecordInventoryStore = { * Configures record action routing without enabling spending coordination. * @property chainId Optional chain identity for plans. Defaults to the transport network. * @property tokenJoin Optional dynamic router for ARC20/ARC22 joins. Defaults to native pairwise joins. - * @property store Legacy option accepted for compatibility but unused by recordActions. - * Configure recordManagement with enabled: true on the client to share a journal. - * @property maxFeeMicrocredits Legacy option accepted for compatibility but unused by recordActions. - * Configure recordManagement with enabled: true on the client to enforce a fee ceiling. + * @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 @@ -146,16 +146,19 @@ export type RecordActionsConfig = { } /** - * Enables shared record reservations and transaction recovery on a client. - * Management is off unless enabled is true; merely exposing record actions does - * not allocate a journal or change ordinary writes. Disabled settings may still - * supply a chain identity and token router for explicit record actions. - * @property enabled Explicit opt-in for reservation and recovery. Defaults to false when omitted from client configuration. - * @property store Shared atomic journal. Defaults to one in-memory store per enabled client; unused when disabled. + * 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 proved transaction fee in microcredits (u64), before broadcast. - * Optional, with no limit by default; ignored when disabled. + * @property maxFeeMicrocredits Maximum fee in microcredits (u64), checked on the + * proved transaction before broadcast. Defaults to no limit; ignored when disabled. * @property tokenJoin Optional ARC20/ARC22 join router. Defaults to native pairwise joins; credits are unaffected. * @example * const recordManagement: RecordManagementConfig = { diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index b221ca2b..0b746bcb 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -163,18 +163,20 @@ resolves and caches witnesses automatically for local and delegated proving. ## Inventory scanning and proving -Wallet clients already expose inventory methods. Pass +Wallet clients include inventory methods. Pass `recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }` to -`createAleoClient` to opt into shared reservations and recovery. The same option -is accepted by `createDevnodeClient`; a local chain should use a distinct -`chainId`. Omission or `enabled: false` leaves ordinary writes unchanged. -Use `createRemoteScanner({ waitForSync: true })` for inventory maintenance. Synchronization polling uses the existing SDK scanner; -`syncTimeoutMs` defaults to 300000 milliseconds and `waitForSync` defaults to false -for existing consumers. Both local and delegated execution honor an awaited -`transaction-prepared` callback before submission, allowing an application to -checkpoint a proved transaction. Storage is supplied by the application; SQLite -belongs only to the separate Veil CLI. See the -[inventory guide](../../site/docs/guides/record-inventory.md). +`createAleoClient` to reserve records during writes and track unfinished +transactions. `createDevnodeClient` accepts the same option; use a distinct +`chainId` for each local chain. Omission or `enabled: false` leaves ordinary +writes unchanged. + +Use `createRemoteScanner({ waitForSync: true })` to wait for the scanner to +catch up before planning inventory changes. `syncTimeoutMs` defaults to +300000 milliseconds; `waitForSync` defaults to false. Local and delegated +execution both await the `transaction-prepared` callback before submission, +so an application can save the proved transaction for recovery. The application +supplies storage; SQLite is available only through the separate Veil CLI. See +the [inventory guide](../../site/docs/guides/record-inventory.md). Configured token join routers supply the underlying token as a dynamic import; the proving adapter includes that program and its transitive static imports for diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index b51277e9..d4dcf035 100644 --- a/packages/provable-sdk/src/index.ts +++ b/packages/provable-sdk/src/index.ts @@ -411,7 +411,8 @@ export interface AleoSdk { * 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; enabled: true opts in before wallet actions are bound. + * 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. @@ -680,8 +681,8 @@ 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 } - // Dynamic token programs can import other programs themselves; include - // those transitive sources so routed joins can be proved completely. + // 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) { const dynamicSource = await programManager.networkClient.getProgram(name) Object.assign(merged, await programManager.networkClient.getProgramImports(dynamicSource)) @@ -877,8 +878,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? false, - // An awaited checkpoint requires control of broadcast to stay with - // this client until the proved transaction has been saved. + // Submit from this client when a progress callback is present, + // so it can save the proved transaction before broadcast. broadcast: !execOptions.onProgress, useFeeMaster: options.useFeeMaster ?? false, }) @@ -968,8 +969,8 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo throw new ProvingError({ message: e instanceof Error ? e.message : String(e), cause: e as Error }) } - // Convert the WASM transaction into the serializable value stored by - // recovery before the network can observe this execution. + // 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 @@ -1486,7 +1487,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo transport, proving, recordProvider: records, - // Forward opt-in before wallet methods close over their client. + // Pass record settings when creating the client so every wallet action uses them. recordManagement: options.recordManagement, }).extend(provableApiActions()) @@ -1687,8 +1688,8 @@ export function createDevnodeClient(options?: { imports: execOptions.programImports, }) - // Preserve the same prepare-before-broadcast checkpoint used by hosted - // execution so recovery behavior also holds against the local devnode. + // 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 }) diff --git a/packages/shield-swap-cli/README.md b/packages/shield-swap-cli/README.md index 18fdc0df..f3298ad7 100644 --- a/packages/shield-swap-cli/README.md +++ b/packages/shield-swap-cli/README.md @@ -235,11 +235,12 @@ position's withdrawal address as the proof subject. ## Share record reservations with inventory maintenance -Set `VEIL_INVENTORY_DB` to the same journal path used by `veil inventory run`. -The session sets `recordManagement: { enabled: true, store, chainId }` during -client construction so trading and maintenance cannot reserve the same explicit -record inputs. This optional -CLI integration loads `@provablehq/veil-cli/storage` and requires Node 22.13+. -Without the environment variable, record coordination remains disabled and no -inventory journal is opened. Exposing record methods does not enable it. -See the [Veil CLI reference](../cli/README.md) for policy configuration and recovery. +Set `VEIL_INVENTORY_DB` to the journal path used by `veil inventory run` so +trading and maintenance cannot reserve the same explicit record inputs. The +session enables `recordManagement: { enabled: true, store, chainId }` when it +creates the client. This option loads `@provablehq/veil-cli/storage` and requires +Node 22.13+. + +Without the environment variable, the CLI keeps record coordination disabled +and opens no inventory journal. Record methods alone do not enable it. See the +[Veil CLI reference](../cli/README.md) for policy configuration and recovery. diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index 34956cd6..929ac25b 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -252,7 +252,7 @@ export async function loadSession(options: { network?: string } = {}) { const { walletClient, account } = aleo.createAleoClient({ privateKey: state.privateKey, networkUrl: NETWORK_URL, - // Constructor opt-in reaches both ordinary writes and Shield Swap actions. + // Apply the shared reservations to wallet writes and Shield Swap actions. recordManagement: inventoryStore ? { enabled: true, store: inventoryStore, chainId: `aleo:${network}`, tokenJoin: { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' }, diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index 41b3af53..460ef9f8 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -1351,16 +1351,20 @@ for deployments that accept them. Omission now checks the actual current list. ## Record inventory Configure `recordManagement.tokenJoin` with -`{ program: 'main_aj_arc20_2_15.aleo' }` at client construction to batch -underlying ARC20/ARC22 joins on mainnet. Use -`test_aj_arc20_2_15.aleo` on testnet. The DEX inventory wrappers preserve this -configuration, target counts, shared reservations, and exact-output recovery. +`{ program: 'main_aj_arc20_2_15.aleo' }` when creating the client to join several +underlying ARC20/ARC22 records in one transaction on mainnet. Use +`test_aj_arc20_2_15.aleo` on testnet. The inventory wrappers use this router and +the requested record count. When reservations and recovery are enabled, they +also reserve inputs and match confirmed outputs before continuing a plan. Reservations and recovery are off by default. Set -`recordManagement: { enabled: true, store, chainId }` when constructing the -wallet client, then apply `shieldSwapActions` to coordinate maintenance with -trading. Adding `recordActions()` alone never enables coordination. `planInventory({ token, target })` and -`rebalanceInventory({ token, target })` resolve the DEX token's underlying program -and delegate to core's ARC20/ARC22/credits helpers. Participating clients must -share the same store and chain identity. No database dependency is added to the -SDK. See the [inventory guide](../../site/docs/guides/record-inventory.md). +`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 ARC20/ARC22/credits actions. Participating clients MUST share the +same store and chain identity. The SDK has no database dependency. + +Follow the [record inventory guide](../../site/docs/guides/record-inventory.md) +for configuration, shared storage, and recovery after an interruption. diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts index 9047c0c8..9f2dc8be 100644 --- a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -15,8 +15,8 @@ export type RebalanceInventoryParameters = PlanInventoryParameters & Omit { - // Resolve the DEX token and build its underlying-asset plan before handing - // execution, reservations, and dependent-output checks to the core actions. + // 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 2641a05a..1dfb24e7 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -2,15 +2,19 @@ A private token balance can span many records. A swap may need one record large enough to spend, while concurrent trades benefit from several independent -records. Inventory actions join and split existing records to shape that balance. +records. Inventory actions join or split these records to prepare the balance +for spending. ## Enable record coordination -Wallet clients already expose record inventory methods. Reservations and recovery -are **off by default**, including after `.extend(recordActions())`. Reads, -planning, and individual joins or splits remain available without coordination. -Set `recordManagement.enabled: true` when constructing a client to coordinate -writes, execute inventory plans, and recover interrupted operations. +Wallet clients include record inventory methods. Reads, planning, and individual +joins or splits work without enabling reservations or recovery. + +Reservations keep participating clients from spending the same record. Recovery +tracks unfinished transactions so an interrupted operation can be reconciled +before more records are spent. Both are off by default, including after +`.extend(recordActions())`. Set `recordManagement.enabled: true` when creating +the client to enable them and execute plans with dependent steps. ```ts import { loadNetwork } from '@provablehq/veil-aleo-sdk' @@ -37,22 +41,25 @@ const result = await client.rebalanceRecordInventory({ }) ``` -Core provides runtime-independent planning, execution, and storage interfaces. -The Aleo SDK supplies the existing record scanner and local/delegated proving. -An enabled client defaults to an in-memory reservation store; an omitted or -disabled configuration allocates none. Long-running services MUST supply -an appropriate durable `RecordInventoryStore` through `recordManagement.store`; SQLite is available exclusively -from the separate `@provablehq/veil-cli/storage` application package. +Core defines the planning, execution, and storage interfaces without requiring +a specific runtime. The Aleo SDK supplies the scanner and local or delegated +proving. + +An enabled client uses an in-memory store unless the caller supplies one. +Disabled clients create no store. To recover unfinished operations after a +restart, long-running services MUST supply a durable `RecordInventoryStore` +through `recordManagement.store`. SQLite storage is available only from the +separate `@provablehq/veil-cli/storage` application package. | Action | Behavior | | --- | --- | | `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | | `joinRecords({ asset, records })` | Joins two native records, or 2–15 token records with a configured router; returns the transaction id. | | `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | -| `autoJoin({ asset, minAmount? })` | Consolidates enough records to cover an amount, or all records when omitted; execution requires enabled management. | +| `autoJoin({ asset, minAmount? })` | Joins enough records to cover the amount, or all records if the amount is omitted. Execution requires record management to be enabled. | | `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | -| `rebalanceRecordInventory({ plan, ... })` | Requires enabled management; executes sequentially and waits for confirmed scanner outputs. | -| `reconcileRecordInventory({ rebroadcast? })` | Requires enabled management; resolves pending journal entries with optional exact-transaction rebroadcast. | +| `rebalanceRecordInventory({ plan, ... })` | Runs each step and waits for its confirmed outputs to appear in the scanner. Requires record management to be enabled. | +| `reconcileRecordInventory({ rebroadcast? })` | Resolves pending journal entries and can rebroadcast the saved transaction. Requires record management to be enabled. | Assets use `standard: 'credits'`, `'arc20'`, or `'arc22'`. Credits MUST use `credits.aleo`. ARC tokens are checked against their deployed Token and join/split @@ -74,11 +81,13 @@ width. `maxTransactions` defaults to 100. Configure `recordManagement.tokenJoin: { program, maxRecords? }` to route ARC20 and ARC22 joins through a dedicated dynamic join program. Mainnet uses `main_aj_arc20_2_15.aleo`; testnet uses `test_aj_arc20_2_15.aleo`. `maxRecords` -defaults to 15 and accepts 2–15. Core has no network-specific router default; -omitting `tokenJoin` preserves native pairwise joins. Credits remain native. -Routing also works with `enabled: false` for planning or individual joins; it -does not enable reservations. The optional `recordActions({ tokenJoin })` -decorator remains available for custom clients without constructor settings. +defaults to 15 and accepts 2 to 15. Core selects no router by default; omitting +`tokenJoin` uses the token program's native join to combine two records at a time. +Credits always use native joins. + +Routing works with `enabled: false` for planning or individual joins, without +reserving records. Custom clients can also use `recordActions({ tokenJoin })` +when no record management settings were supplied during client creation. The planner batches only as many inputs as the target count requires and bounds each summed amount to u128. Sixteen token records consolidate through `join_15` @@ -91,11 +100,12 @@ Each router call prepends the underlying program's identifier (for example, resolves its transitive imports for both local and delegated proving. Wallet adapters must support dynamic calls and pinned underlying-token records. -With management enabled, all batch inputs are reserved atomically. Fees, durable proof checkpoints, and -exact-transaction recovery use the same inventory journal. Confirmed router -outputs carry dynamic ids; execution resolves each id to the matching nested -token join's record commitment before waiting for the scanner. Intermediate -records and unrelated deposits cannot satisfy a dependent step. +With management enabled, each batch reserves all its input records together. +The same journal records fees and saves proved transactions for recovery. +Confirmed router outputs carry dynamic ids. The executor finds each output's +record commitment in the nested token join, then waits for that record to +appear in the scanner. Intermediate records and unrelated deposits cannot +satisfy a dependent step. ## Coordinate with Shield Swap @@ -105,14 +115,16 @@ const plan = await dex.planInventory({ token: 'USDCx', target: { records: 4 } }) const result = await dex.rebalanceInventory({ token: 'USDCx', target: { records: 4 } }) ``` -Import `shieldSwapActions` from `@provablehq/shield-swap-sdk`. The wrapper resolves -token metadata to `underlyingProgram`, then uses the same core actions. -Construct the wallet with `recordManagement: { enabled: true, store, chainId }` -before applying `shieldSwapActions`. Token selection then skips shared reservations and -`writeContract`/`executeTransaction`/`executeContract` reserve explicit record -inputs atomically before proving. Independent clients MUST share the same store -and chain identity. This coordination does not cover raw submissions or other -wallets that do not participate. +Import `shieldSwapActions` from `@provablehq/shield-swap-sdk`. The wrapper reads +`underlyingProgram` from the token metadata and uses core's inventory actions. +Create the wallet with `recordManagement: { enabled: true, store, chainId }` +before applying `shieldSwapActions`. Token selection then skips reserved records. +`writeContract`/`executeTransaction`/`executeContract` reserve all explicit record +inputs together before proving. + +Independent clients MUST share the same store and chain identity to avoid +spending each other's reserved inputs. Raw submissions and wallets outside this +configuration bypass the reservations. ## Interrupted work and wallets @@ -149,12 +161,13 @@ restart journal, use the [inventory CLI](../packages/cli.md). ## Migrate a recordActions configuration Move `store`, `chainId`, `maxFeeMicrocredits`, and `tokenJoin` into the client's -`recordManagement` configuration and add `enabled: true` to retain coordination. -The same option is accepted by `createClient`, `createWalletClient`, -`aleo.createAleoClient`, and `createDevnodeClient`. Wallet clients need no -`recordActions` extension to expose the methods. - -`recordActions({ store, maxFeeMicrocredits })` alone does not reserve records, -limit fees, or enable recovery. These legacy options remain accepted but are -unused. Its `chainId` and `tokenJoin` options still configure record actions when -constructor settings are absent; constructor settings take precedence. +`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. + +`recordActions({ store, maxFeeMicrocredits })` accepts these options but ignores +them: they do not reserve records, enforce fee limits, or enable recovery. +Its `chainId` and `tokenJoin` options apply only when the client was created +without record management settings. Settings supplied during client creation +take precedence. From f527032c4e9fb18043ee797fae81c5a3b9c02313 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 11:14:03 -0400 Subject: [PATCH 08/32] [Feat] Infer record assets and select batch join helpers --- .changeset/record-inventory.md | 4 +- README.md | 6 +- apps/loyalty-dapp/src/lib/aleo.ts | 4 +- examples/e2e-demo.ts | 4 +- packages/cli/README.md | 36 +++-- packages/cli/src/config.ts | 21 +-- packages/cli/src/index.ts | 10 +- packages/cli/test/runner.test.ts | 22 ++- packages/core/README.md | 14 +- packages/core/src/actions/records/autoJoin.ts | 4 +- .../src/actions/records/getRecordInventory.ts | 15 +- packages/core/src/actions/records/internal.ts | 97 +++++++---- .../core/src/actions/records/joinRecords.ts | 48 ++++-- .../actions/records/planRecordInventory.ts | 6 +- .../records/rebalanceRecordInventory.ts | 9 +- .../core/src/actions/records/splitRecord.ts | 11 +- packages/core/src/index.ts | 1 + packages/core/src/inventory/internal.ts | 4 +- packages/core/src/inventory/joinPrograms.ts | 134 ++++++++++++++++ packages/core/src/inventory/planner.ts | 31 ++-- packages/core/src/inventory/spending.ts | 2 +- packages/core/src/inventory/tokenJoin.ts | 16 +- packages/core/src/inventory/types.ts | 66 ++++++-- .../core/test/inventory/assetDefaults.test.ts | 131 +++++++++++++++ .../test/inventory/autojoinHelpers.test.ts | 150 ++++++++++++++++++ .../core/test/inventory/inventory.test.ts | 9 +- packages/provable-sdk/README.md | 2 +- packages/shield-swap-cli/src/session.ts | 1 - packages/shield-swap/README.md | 12 +- .../src/actions/inventory/planInventory.ts | 20 +-- .../test/actions/inventory.test.ts | 4 +- site/docs/guides/record-inventory.md | 134 +++++++++++----- 32 files changed, 826 insertions(+), 202 deletions(-) create mode 100644 packages/core/src/inventory/joinPrograms.ts create mode 100644 packages/core/test/inventory/assetDefaults.test.ts create mode 100644 packages/core/test/inventory/autojoinHelpers.test.ts diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index 80f168b7..3cdc0a7c 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -8,4 +8,6 @@ Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add the separate Veil inventory CLI with bounded maintenance policies and a CLI-only SQLite recovery journal. SDK packages retain their existing runtime requirements. -Add configurable dynamic token join routing with batches of 2–15 records. Inventory plans preserve target counts, reservations, proved fee limits, and recovery while resolving dynamic router outputs to underlying token commitments. The CLI selects the network's deployed ARC router by default; native pairwise joins remain available. +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. diff --git a/README.md b/README.md index 2976925c..9087310d 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,10 @@ const tools = aleoAgentTools({ ## Record inventory management Core reads records from a scanner and joins or splits them to prepare an -ARC20, ARC22, or native credits balance for spending. Shield Swap applies these -operations to each DEX token's underlying program. The separate +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. The separate [`@provablehq/veil-cli`](packages/cli/README.md) runs background maintenance and stores recovery data in SQLite. diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index dd0a6597..517bc34b 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -35,7 +35,7 @@ export function createAleoWalletClient(adapter: AleoWalletAdapter) { // 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. - recordManagement: { enabled: false, chainId: 'aleo:mainnet', tokenJoin: { program: 'main_aj_arc20_2_15.aleo' } }, + recordManagement: { enabled: false, chainId: 'aleo:mainnet' }, }) } @@ -71,7 +71,7 @@ export async function getLoyaltyContract(walletClient?: ReturnType) { diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index f9ab8299..5de27e47 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -248,14 +248,14 @@ describe('Record inventory consumer example', () => { 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', tokenJoin: { program: 'test_aj_arc20_2_15.aleo' } }, + 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', standard: 'credits' }, + asset: { program: 'credits.aleo' }, target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, }) expect(client.recordManagement?.enabled).toBe(false) diff --git a/packages/cli/README.md b/packages/cli/README.md index ac76f95d..4fa34846 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,7 +1,7 @@ # @provablehq/veil-cli Run scanner-backed record inventory management for native `credits.aleo`, ARC20, -and ARC22 tokens. Requires Node **22.13+** for built-in SQLite. SDK consumers do +ARC22, and compatible custom records. Requires Node **22.13+** for built-in SQLite. SDK consumers do not install this application or SQLite. ## Start @@ -36,10 +36,9 @@ Within this repository, use `pnpm veil inventory ...`. "maxFeeMicrocredits": "1000000", "maxDailyFeeMicrocredits": "10000000", "useFeeMaster": false, - "tokenJoin": { "program": "test_aj_arc20_2_15.aleo", "maxRecords": 15 }, "policies": [ { - "asset": { "program": "credits.aleo", "standard": "credits" }, + "asset": { "program": "credits.aleo" }, "target": { "records": 4, "minRecordAmount": "1000000", "distribution": "preserve" }, "countRange": [3, 6] } @@ -61,11 +60,11 @@ reuse the default identity across independent local chains. 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`: dynamic ARC20/ARC22 join router. Defaults to - `test_aj_arc20_2_15.aleo` on testnet or `main_aj_arc20_2_15.aleo` on mainnet. - Set `{ "program": "custom_router.aleo", "maxRecords": 15 }` for another - deployment, or `false` for native pairwise joins. The batch ceiling is 2–15. - Sixteen token records require two transactions; credits keep native joins. +- `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 @@ -76,10 +75,22 @@ reuse the default identity across independent local chains. 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. -For tokens, set `standard` to `arc20` or `arc22` and use the underlying program, -not an AMM wrapper or `token_registry.aleo`. Bound ARC22 records and compliance -records are excluded. The application reuses `createRemoteScanner` and waits for -its reported synchronization before planning. +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 +veil 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 @@ -119,7 +130,6 @@ const { walletClient } = aleo.createAleoClient({ privateKey, // supplied by the application recordManagement: { enabled: true, store, chainId: 'aleo:testnet', - tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, }, }) // Extend walletClient with shieldSwapActions(...) when trading. diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 78c622ca..a333c268 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -2,7 +2,7 @@ import type { InventoryTarget, RecordAsset, TokenJoinRouter } from '@provablehq/ /** * Defines one asset's maintenance policy in base units. - * @property asset Native credits, ARC20 or ARC22 program. + * @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 @@ -27,7 +27,7 @@ export type InventoryPolicy = { * @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 Token join router; defaults to the network's ARC router. Set false to use native pairwise joins. + * @property tokenJoin Optional dynamic ARC router override. Omission selects available helpers by asset and network; false uses native pairwise joins. * @internal */ export type InventoryConfig = { @@ -43,7 +43,7 @@ export type InventoryConfig = { maxFeeMicrocredits: bigint maxDailyFeeMicrocredits: bigint useFeeMaster: boolean - tokenJoin?: TokenJoinRouter + tokenJoin?: TokenJoinRouter | false } /** @@ -76,11 +76,11 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { } const network = raw.network ?? 'testnet' if (network !== 'testnet' && network !== 'mainnet') throw new Error('Network must be testnet or mainnet') - // CLI policies choose the deployed router for their network by default; an - // explicit false keeps native pairwise joins available for other deployments. - const router = raw.tokenJoin ?? { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' } - let tokenJoin: TokenJoinRouter | undefined - if (router !== false) { + // 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') @@ -95,8 +95,9 @@ export function parseInventoryConfig(value: unknown): InventoryConfig { // 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) || !['credits', 'arc20', 'arc22'].includes(asset.standard)) throw new Error('Invalid inventory asset') - if ((asset.standard === 'credits') !== (asset.program === 'credits.aleo')) throw new Error('credits.aleo requires the credits standard') + 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) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 805291d6..e48f904c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,8 +10,10 @@ import { runInventoryCycle } from './runner.js' const HELP = `veil inventory --config JSON policy configuration - --asset One-off asset program (credits.aleo or an ARC token) - --standard credits, arc20 or arc22 (required for non-credits assets) + --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 @@ -27,7 +29,7 @@ const print = (value: unknown) => console.log(JSON.stringify(value, (_, item) => async function main(): Promise { const { values, positionals } = parseArgs({ allowPositionals: true, options: { - config: { type: 'string' }, asset: { type: 'string' }, standard: { type: 'string' }, records: { type: 'string' }, + config: { type: 'string' }, asset: { type: 'string' }, standard: { type: 'string' }, 'record-name': { type: 'string' }, 'amount-field': { type: 'string' }, records: { type: 'string' }, network: { type: 'string' }, database: { type: 'string' }, execute: { type: 'boolean' }, help: { type: 'boolean' }, } }) if (values.help || !positionals.length) { console.log(HELP); return } @@ -37,7 +39,7 @@ async function main(): Promise { const raw = values.config ? JSON.parse(await readFile(values.config, 'utf8')) : {} if (values.network) raw.network = values.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 ?? (values.asset === 'credits.aleo' ? 'credits' : undefined) }, target: { records: Number(values.records ?? '1') } }] + 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] diff --git a/packages/cli/test/runner.test.ts b/packages/cli/test/runner.test.ts index 9db4dfeb..07a09c30 100644 --- a/packages/cli/test/runner.test.ts +++ b/packages/cli/test/runner.test.ts @@ -8,15 +8,23 @@ const policy = { asset: { program: 'credits.aleo', standard: 'credits' }, target beforeEach(() => { vi.resetAllMocks(); vi.mocked(reconcileRecordInventory).mockResolvedValue([]) }) describe('inventory application policies', () => { - it('selects network routers and supports native or bounded custom joins', () => { - expect(parseInventoryConfig({}).tokenJoin).toEqual({ program: 'test_aj_arc20_2_15.aleo', maxRecords: 15 }) - expect(parseInventoryConfig({ network: 'mainnet' }).tokenJoin).toEqual({ program: 'main_aj_arc20_2_15.aleo', maxRecords: 15 }) - expect(parseInventoryConfig({ tokenJoin: false }).tokenJoin).toBeUndefined() - expect(parseInventoryConfig({ tokenJoin: { program: 'custom_router.aleo', maxRecords: 4 } }).tokenJoin?.maxRecords).toBe(4) + 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', @@ -47,14 +55,14 @@ describe('inventory application policies', () => { expect(planRecordInventory).not.toHaveBeenCalled() }) it('includes intrinsic credits deductions in the rolling fee budget', async () => { - vi.mocked(getRecordInventory).mockResolvedValue({ available: [], reserved: [], balance: 100n }) + 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({ available: [], reserved: [], balance: 100n }) + 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' } }] }) diff --git a/packages/core/README.md b/packages/core/README.md index 694dd189..4c9ea74a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -260,10 +260,16 @@ to `createWalletClient` or `createClient`. An enabled client creates an in-memor store if none is supplied. Disabled clients create no store. Core has no SQLite or Node dependency. -Configure `tokenJoin: { program: 'main_aj_arc20_2_15.aleo' }` on mainnet (or -`test_aj_arc20_2_15.aleo` on testnet) to join 2 to 15 ARC20/ARC22 records in one -transaction. Routing also works with `enabled: false`. Without a router, token -joins combine two records at a time. Credits always use native joins. +An asset needs only its program: `{ program: 'credits.aleo' }`. Optional +`recordName` and `amountField` select a custom record balance. Core reads the +program's join/split interface and defaults to `amount`, then `microcredits`; +native credits use `credits.microcredits`. + +When more than two records need joining, core prefers a compatible deployed +helper: up to 16 credits or dedicated stablecoin records, or 15 ARC records per +transaction. Set `tokenJoin: false` to use native pairwise joins, or supply +`{ program, maxRecords? }` to override the dynamic ARC router. Helper selection +also works with `enabled: false`; it does not enable reservations or recovery. `recordActions()` adds these methods to custom clients without enabling reservations or recovery. Move `recordActions({ store, maxFeeMicrocredits })` diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index 29b015df..8460b4ce 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -1,3 +1,4 @@ +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' @@ -41,7 +42,8 @@ export async function autoJoin(client: Client, params: AutoJoinParameters): Prom 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 plan = buildInventoryPlan({ scope: scopeOf(client), asset: params.asset, tokenJoin: recordSettings(client).tokenJoin, target: { records: 1 }, + 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 index a9eb86ba..f8a453bb 100644 --- a/packages/core/src/actions/records/getRecordInventory.ts +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -1,32 +1,33 @@ import type { Client } from '../../clients/createClient.js' -import type { InventoryRecord, RecordAsset } from '../../inventory/types.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 and standard whose eligible records are requested. + * @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 = { available: InventoryRecord[]; reserved: InventoryRecord[]; balance: bigint } +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 Token program and standard to inspect. + * @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 - * const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo', standard: 'credits' } }) + * const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo' } }) */ export async function getRecordInventory(client: Client, params: GetRecordInventoryParameters): Promise { // Verify the token interface before interpreting record fields, then read the @@ -41,7 +42,7 @@ export async function getRecordInventory(client: Client, params: GetRecordInvent 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(params.asset)], resultsPerPage: 1000, page } }), + ...(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 @@ -61,5 +62,5 @@ export async function getRecordInventory(client: Client, params: GetRecordInvent // 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 { available, reserved, balance: available.reduce((sum, record) => sum + record.amount, 0n) } + 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 index 824cf9e6..743e1ad2 100644 --- a/packages/core/src/actions/records/internal.ts +++ b/packages/core/src/actions/records/internal.ts @@ -1,48 +1,91 @@ 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 } from '../../inventory/types.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 intrinsic split deduction in base units, independently of network fees. @internal */ -export const splitDeduction = (asset: RecordAsset): bigint => asset.standard === 'credits' ? 10_000n : 0n -/** Selects the native amount width for record parsing and encoded inputs. @internal */ -export const amountWidth = (asset: RecordAsset): 'u64' | 'u128' => asset.standard === 'credits' ? 'u64' : 'u128' -/** Selects the native record type for the scanner filter. @internal */ -export const recordName = (asset: RecordAsset): string => asset.standard === 'credits' ? 'credits' : 'Token' -/** Returns the largest representable record amount for this asset. @internal */ -export const maxAmount = (asset: RecordAsset): bigint => (1n << (asset.standard === 'credits' ? 64n : 128n)) - 1n +/** 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 -/** Verifies the deployed join/split interface and reports required field grants. @internal */ -export async function validateAsset(client: Client, asset: RecordAsset): Promise<{ recipientBound: boolean }> { +/** 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') - if (asset.standard === 'credits') { - if (asset.program !== 'credits.aleo') throw new Error('Native credits require credits.aleo') - return { recipientBound: false } + 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 } } - if (asset.standard !== 'arc20' && asset.standard !== 'arc22') throw new Error('Unsupported token standard') const source = await getCode(client, { programId: asset.program }) - const report = checkProgramConformance(source, asset.standard) - // Inventory depends on the Token and join/split interface, not transfer views. - // Deployed stablecoins can expose these capabilities without newer read accessors. - 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 (report.programId !== asset.program || violations.length) throw new Error(`${asset.program} has an incompatible token join/split interface`) - return { recipientBound: parseProgram(source).records.find((record) => record.name === 'Token')?.fields.some((field) => field.name === 'recipient_bound') ?? false } + 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?: { recipientBound: boolean }): InventoryRecord | undefined { - if (record.programName !== asset.program || record.spent === true) return undefined +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.standard === 'credits' ? 'microcredits' : 'amount' + 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 diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index bb6c51e3..696a07dd 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -1,21 +1,23 @@ import type { Client } from '../../clients/createClient.js' -import type { InventoryRecord, RecordAsset } from '../../inventory/types.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 { recordSettings } from '../../inventory/internal.js' -import { validateTokenJoinFunction } from '../../inventory/tokenJoin.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 and supported standard. - * @property records Distinct records from getRecordInventory; exactly two for native joins, 2–15 with a token router. + * @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[]; onProgress?: ProvingProgressHandler } +export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryRecord[]; joinProgram?: RecordJoinProgram | false; onProgress?: ProvingProgressHandler } /** - * Joins records through a native transition or the configured token router. + * 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. @@ -26,9 +28,18 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryReco */ export async function joinRecords(client: Client, params: JoinRecordsParameters): Promise { const capabilities = await validateAsset(client, params.asset) - const router = params.asset.standard !== 'credits' ? recordSettings(client).tokenJoin : undefined - if (router) await validateTokenJoinFunction(client, router, params.records.length) - else if (params.records.length !== 2) throw new Error('joinRecords requires two records without a token router') + 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)) @@ -36,10 +47,15 @@ export async function joinRecords(client: Client, params: JoinRecordsParameters) 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(params.asset)) throw new Error('Joined amount overflows the token width') - // Routed joins identify the underlying program explicitly and provide it as a - // dynamic import; native joins call the token's own pairwise transition. - return writeContract(client, { program: router?.program ?? params.asset.program, function: router ? `join_${records.length}` : 'join', - inputs: [...(router ? [`'${params.asset.program.slice(0, -5)}'`] : []), ...eligible.map((record) => recordInput(client, params.asset, record))], - ...(router ? { imports: [params.asset.program] } : {}), onProgress: params.onProgress }) + 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 index 39fa75b8..778719f3 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -1,3 +1,4 @@ +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' @@ -6,7 +7,7 @@ import { getRecordInventory } from './getRecordInventory.js' /** * Defines a read-only inventory planning request. - * @property asset Program and supported standard. + * @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. */ @@ -24,8 +25,9 @@ export type PlanRecordInventoryParameters = { asset: RecordAsset; target: Invent */ 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, tokenJoin: recordSettings(client).tokenJoin, + 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 index f0972d2c..8924fb87 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -7,7 +7,7 @@ import { requestRecords } from '../wallet/requestRecords.js' import { getRecordInventory } from './getRecordInventory.js' import { joinRecords } from './joinRecords.js' import { splitRecord } from './splitRecord.js' -import { decodeRecord } from './internal.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' @@ -61,6 +61,9 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance } 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 @@ -93,7 +96,7 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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, onProgress }) + ? 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) @@ -127,7 +130,7 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance ...(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) + const decoded = record && decodeRecord(client, plan.asset, record, capabilities) return decoded ? [decoded] : [] }) if (outputs.length !== commitments.length) await pause() diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts index d9e7840e..6b46c3e8 100644 --- a/packages/core/src/actions/records/splitRecord.ts +++ b/packages/core/src/actions/records/splitRecord.ts @@ -6,9 +6,9 @@ import { amountWidth, decodeRecord, recordInput, splitDeduction, validateAsset } /** * Supplies an owned record for a native split. - * @property asset Program and supported standard. + * @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 (u64 credits, u128 tokens). + * @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 } @@ -17,7 +17,7 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor * 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 (u64 credits, u128 tokens). + * @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 @@ -25,11 +25,12 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor */ 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(params.asset) >= record.amount) throw new Error('Split must produce two positive eligible records') + 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, params.asset, record), `${params.amount}${amountWidth(params.asset)}`], onProgress: params.onProgress }) + inputs: [recordInput(client, asset, record), `${params.amount}${amountWidth(asset)}`], onProgress: params.onProgress }) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db54dcab..786678e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -267,6 +267,7 @@ export { isArc22, type IsArc22Parameters, type IsArc22ReturnType } from './actio // 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' diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts index b84a4ab3..c0650a8a 100644 --- a/packages/core/src/inventory/internal.ts +++ b/packages/core/src/inventory/internal.ts @@ -12,7 +12,8 @@ import type { Transport } from '../types/transport.js' * @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 router used for token consolidation; absent uses native joins. + * @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 */ @@ -21,6 +22,7 @@ export type RecordManagement = Required> & store: RecordInventoryStore maxFeeMicrocredits?: bigint tokenJoin?: RecordActionsConfig['tokenJoin'] + joinAsset?: { program: string; assetProgram: string } network: string | null | undefined } 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 index eaf4acb6..d9c357a3 100644 --- a/packages/core/src/inventory/planner.ts +++ b/packages/core/src/inventory/planner.ts @@ -1,25 +1,29 @@ -import type { InventoryPlan, InventoryStep, InventoryTarget, RecordAsset, TokenJoinRouter } from './types.js' +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 shape and optional operation ceiling (default 100). + * @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 + inputs: { id: string; amount: bigint }[]; maxTransactions?: number; tokenJoin?: TokenJoinRouter | false; joinPrograms?: RecordJoinProgram[] }): InventoryPlan { // Capture the requested policy and validate bounds before simulating any steps. - // Credits always use native pairwise joins, even when a token router is configured. const { asset } = params if (params.tokenJoin) validateTokenJoinRouter(params.tokenJoin) - const tokenJoin = asset.standard !== 'credits' && params.tokenJoin ? { ...params.tokenJoin } : undefined - const routing = tokenJoin ? { 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') @@ -88,16 +92,23 @@ export function buildInventoryPlan(params: { // 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)) - let count = Math.min(maxJoinRecords, working.length - reduceTo + 1) + 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) - // Keep the batch within u128 and leave the requested number of records. - while (count > 2 && amount > maxAmount(asset)) amount -= working[--count]!.amount + // 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', inputs: consumed.map((record) => record.id), outputs: [output], deduction: 0n }) + 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 diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts index cf7ac300..cb6cc960 100644 --- a/packages/core/src/inventory/spending.ts +++ b/packages/core/src/inventory/spending.ts @@ -58,7 +58,7 @@ export async function manageRecordSpend(client: Client, params: { 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.tokenJoin?.program === params.program && /^join_\d+$/.test(params.function) && Array.isArray(params.imports) + 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 // Acquire all input nonces together before invoking any proving or wallet call. const entry = await config.store.acquire({ scope, records: ids, program: params.program, ...(assetProgram ? { assetProgram } : {}), diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts index bfb316b4..fcfef3c6 100644 --- a/packages/core/src/inventory/tokenJoin.ts +++ b/packages/core/src/inventory/tokenJoin.ts @@ -52,9 +52,9 @@ export async function validateTokenJoinFunction(client: Client, router: TokenJoi * 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 and standard from the reviewed plan. + * @param asset Underlying record program from the reviewed plan. * @param step Planned join or split whose outputs must be matched. - * @param router Optional captured token router; omission expects a native transition. + * @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 dynamic output cannot be matched unambiguously. * @example @@ -66,13 +66,15 @@ export function inventoryOutputCommitments(transaction: Transaction, asset: Reco // 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 routed = step.kind === 'join' && asset.standard !== 'credits' && router - if (outer?.program !== (routed ? router.program : asset.program) || - outer.function !== (routed ? `join_${step.inputs.length}` : step.kind)) { + 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') } - // Native transitions already expose the commitments that scanners index. - if (!routed) return (outer.outputs ?? []).filter((output) => output.type === 'record' || output.type === 'record_with_dynamic_id').map((output) => output.id) + // Native and static helper outputs expose the commitments that scanners index. + if (helper ? helper.inputMode === 'records' : !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') // A router returns a dynamic id, not a scanner commitment. Only the matching diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts index f1e99a6d..1df8699b 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -2,13 +2,35 @@ import type { OwnedRecordEncrypted } from '../types/records.js' import type { Transaction } from '../types/transaction.js' /** - * Describes the program whose fungible records are managed. - * @property program Deployed program id; native credits MUST use credits.aleo. - * @property standard Record and transition shape, verified against deployed code. + * 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', standard: 'credits' } + * 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' } +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. @@ -19,10 +41,22 @@ export type RecordAsset = { program: string; standard: 'credits' | 'arc20' | 'ar */ 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 u64 for credits or u128 for tokens. + * @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 } @@ -44,13 +78,15 @@ export type InventoryTarget = { /** * 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 native records or 2–15 routed token records. + * @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 @@ -60,23 +96,25 @@ export type InventoryStep = { /** * Captures a read-only inventory plan bound to an account and chain. * @property scope Chain/account reservation namespace. - * @property asset Verified token program and standard. - * @property target Requested inventory shape. + * @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 token router captured at planning time; absent uses native pairwise joins. + * @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 + 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. */ @@ -132,7 +170,7 @@ export type RecordInventoryStore = { /** * Configures record action routing without enabling spending coordination. * @property chainId Optional chain identity for plans. Defaults to the transport network. - * @property tokenJoin Optional dynamic router for ARC20/ARC22 joins. Defaults to native pairwise joins. + * @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 @@ -142,7 +180,7 @@ export type RecordActionsConfig = { store?: RecordInventoryStore chainId?: string maxFeeMicrocredits?: bigint - tokenJoin?: TokenJoinRouter + tokenJoin?: TokenJoinRouter | false } /** @@ -159,7 +197,7 @@ export type RecordActionsConfig = { * 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 ARC20/ARC22 join router. Defaults to native pairwise joins; credits are unaffected. + * @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', 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..7f9df3cf --- /dev/null +++ b/packages/core/test/inventory/autojoinHelpers.test.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createWalletClient, memoryRecordInventoryStore, type RecordAsset, type RecordManagementConfig } from '../../src/index.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 transaction = { id: `at1tx${calls.length}`, type: 'execute', execution: { transitions: [ + ...(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: 'dynamicfield' }] : 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([]) + }) +}) diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index 48a12867..ab829e01 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -29,7 +29,11 @@ function world(amounts: bigint[], asset = credits, tokenJoin?: TokenJoinRouter) 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') return params.programId === tokenJoin?.program ? routerSource : source(asset) + 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 }) @@ -133,6 +137,7 @@ describe('scanner-backed inventory', () => { 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, @@ -200,6 +205,7 @@ describe('inventory execution and recovery', () => { 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, @@ -347,6 +353,7 @@ describe('configured token batch joins', () => { 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, diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index 0b746bcb..322eb377 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -178,6 +178,6 @@ so an application can save the proved transaction for recovery. The application supplies storage; SQLite is available only through the separate Veil CLI. See the [inventory guide](../../site/docs/guides/record-inventory.md). -Configured token join routers supply the underlying token as a dynamic import; +Dynamic ARC join helpers supply the underlying token as a dynamic import; the proving adapter includes that program and its transitive static imports for both local and delegated proofs. diff --git a/packages/shield-swap-cli/src/session.ts b/packages/shield-swap-cli/src/session.ts index 929ac25b..6b6515dc 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -255,7 +255,6 @@ export async function loadSession(options: { network?: string } = {}) { // Apply the shared reservations to wallet writes and Shield Swap actions. recordManagement: inventoryStore ? { enabled: true, store: inventoryStore, chainId: `aleo:${network}`, - tokenJoin: { program: network === 'mainnet' ? 'main_aj_arc20_2_15.aleo' : 'test_aj_arc20_2_15.aleo' }, } : undefined, // Faucet-funded accounts hold no public credits; the delegated prover // pays fees from its FeeMaster account. Opt out with diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index 460ef9f8..aee7728d 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -1350,12 +1350,12 @@ for deployments that accept them. Omission now checks the actual current list. ## Record inventory -Configure `recordManagement.tokenJoin` with -`{ program: 'main_aj_arc20_2_15.aleo' }` when creating the client to join several -underlying ARC20/ARC22 records in one transaction on mainnet. Use -`test_aj_arc20_2_15.aleo` on testnet. The inventory wrappers use this router and -the requested record count. When reservations and recovery are enabled, they -also reserve inputs and match confirmed outputs before continuing a plan. +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 diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts index b0af9654..c3b3568d 100644 --- a/packages/shield-swap/src/actions/inventory/planInventory.ts +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -1,10 +1,9 @@ -import { planRecordInventory, getCode, checkProgramConformance, type Client, type InventoryPlan, type InventoryTarget, type RecordAsset } from '@provablehq/veil-core' +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' -import { detectTokenStandard } from '../../utils/detectTokenStandard.js' /** - * Defines the DEX token and desired spendable record shape. + * 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. @@ -28,16 +27,7 @@ export async function planInventory(client: Client, params: PlanInventoryParamet // wrapper program used when routing a swap. const program = token.underlyingProgram if (!program) throw new Error(`Token ${token.symbol} has no underlying record program`) - let standard: RecordAsset['standard'] | 'none' = program === 'credits.aleo' ? 'credits' : await detectTokenStandard(client, { programId: program, engine: 'pure' }) - if (standard === 'none') { - // Some deployed tokens omit newer read views but retain the required token - // interface; tolerate only those missing views, not other conformance errors. - const source = await getCode(client, { programId: program }) - for (const candidate of ['arc22', 'arc20'] as const) { - if (checkProgramConformance(source, candidate).violations.every((violation) => violation.kind === 'missing_view')) { standard = candidate; break } - } - } - if (standard === 'none') throw new Error(`Unsupported inventory asset: ${program}`) - const asset: RecordAsset = { program, standard } - return planRecordInventory(client, { asset, target: params.target, maxTransactions: params.maxTransactions }) + // 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/test/actions/inventory.test.ts b/packages/shield-swap/test/actions/inventory.test.ts index 799e6bbd..1fa810a6 100644 --- a/packages/shield-swap/test/actions/inventory.test.ts +++ b/packages/shield-swap/test/actions/inventory.test.ts @@ -21,7 +21,9 @@ describe('Shield Swap inventory', () => { }) 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' }) + 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 () => { diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 1dfb24e7..26a95311 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -26,10 +26,9 @@ const { walletClient: client } = aleo.createAleoClient({ recordManagement: { enabled: true, chainId: 'aleo:testnet', - tokenJoin: { program: 'test_aj_arc20_2_15.aleo' }, }, }) -const asset = { program: 'credits.aleo', standard: 'credits' } as const +const asset = { program: 'credits.aleo' } const plan = await client.planRecordInventory({ asset, target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, @@ -54,20 +53,46 @@ separate `@provablehq/veil-cli/storage` application package. | Action | Behavior | | --- | --- | | `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | -| `joinRecords({ asset, records })` | Joins two native records, or 2–15 token records with a configured router; returns the transaction id. | +| `joinRecords({ asset, records })` | Joins two records natively, or up to 16 with an available helper (15 for dynamic ARC helpers); returns the transaction id. | | `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | | `autoJoin({ asset, minAmount? })` | Joins enough records to cover the amount, or all records if the amount is omitted. Execution requires record management to be enabled. | | `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | | `rebalanceRecordInventory({ plan, ... })` | Runs each step and waits for its confirmed outputs to appear in the scanner. Requires record management to be enabled. | | `reconcileRecordInventory({ rebroadcast? })` | Resolves pending journal entries and can rebroadcast the saved transaction. Requires record management to be enabled. | -Assets use `standard: 'credits'`, `'arc20'`, or `'arc22'`. Credits MUST use -`credits.aleo`. ARC tokens are checked against their deployed Token and join/split -interfaces. Compliance, registry, and recipient-bound records are excluded. -Amounts use `bigint`: u64 microcredits for credits and u128 base units for tokens. +## Select the record and amount field + +`RecordAsset` requires only `program`. Core reads the deployed program's +join/split interface to select the record and its unsigned amount width. +ARC20/ARC22 tokens use `Token.amount`; native credits use +`credits.microcredits`. For other records, the default field is `amount`, +then `microcredits`. `standard` is an optional hint checked against the program. + +Supply `recordName` or `amountField` to override either default: + +```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. +A missing override is an error; core does not substitute another balance field. +`getRecordInventory` returns the resolved selection in `inventory.asset`. +Compliance, registry, and recipient-bound records are excluded. + +Amounts use `bigint` in base units. Credits are bounded by u64 and ARC tokens by +u128; custom fields use their declared width (u8, u16, u32, u64, or u128). A native credits split deducts 10,000 microcredits from its outputs, separately from any transaction fee. Plans report the total intrinsic deduction. +## Plan joins and splits + `target.records` is 1–1000. `minRecordAmount` defaults to `1n`; `distribution` defaults to `preserve`. `balanced` aims for equal amounts with `toleranceBps` defaulting to 1000 (10%). The planner preserves already suitable @@ -76,36 +101,65 @@ splits. It is a bounded deterministic strategy, not an optimal transaction solver; it rejects layouts whose required intermediate joins overflow the token width. `maxTransactions` defaults to 100. -### Batch token consolidation - -Configure `recordManagement.tokenJoin: { program, maxRecords? }` to route ARC20 -and ARC22 joins through a dedicated dynamic join program. Mainnet uses -`main_aj_arc20_2_15.aleo`; testnet uses `test_aj_arc20_2_15.aleo`. `maxRecords` -defaults to 15 and accepts 2 to 15. Core selects no router by default; omitting -`tokenJoin` uses the token program's native join to combine two records at a time. -Credits always use native joins. - -Routing works with `enabled: false` for planning or individual joins, without -reserving records. Custom clients can also use `recordActions({ tokenJoin })` -when no record management settings were supplied during client creation. - -The planner batches only as many inputs as the target count requires and bounds -each summed amount to u128. Sixteen token records consolidate through `join_15` -and `join_2`, rather than fifteen separate transactions. `maxTransactions` counts -submitted transactions, including batch joins and native splits. Plans capture -their router; existing native plans retain their original execution path. - -Each router call prepends the underlying program's identifier (for example, -`'test_usdcx_stablecoin'`) and supplies the token as a dynamic import. The Aleo SDK -resolves its transitive imports for both local and delegated proving. Wallet -adapters must support dynamic calls and pinned underlying-token records. - -With management enabled, each batch reserves all its input records together. -The same journal records fees and saves proved transactions for recovery. -Confirmed router outputs carry dynamic ids. The executor finds each output's -record commitment in the nested token join, then waits for that record to -appear in the scanner. Intermediate records and unrelated deposits cannot -satisfy a dependent step. +### Batch joins + +When more than two records need joining, core prefers available helper programs +that combine them in one transaction. The exported `recordJoinPrograms` map +lists candidates by network and source asset. Exact program entries take +precedence over ARC defaults. + +| 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 ARC20/ARC22 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 picks the largest compatible batch that preserves the target count +and fits the amount width. Sixteen credits records use one `join_16` call; +seventeen use `join_16`, then a native join with its confirmed output. +Two-record joins use the native program by default. `maxTransactions` counts +submitted transactions, including batch joins and native splits. Plans retain +the selected helpers so execution follows the reviewed plan. + +`recordManagement.tokenJoin` controls overrides: + +- Omit it to select helpers by asset and network. +- Set `false` to use native pairwise joins for every asset. +- Set `{ program, maxRecords? }` to select a dynamic ARC router explicitly. + `maxRecords` defaults to 15 and accepts 2 to 15. This router also handles + two-record token joins; credits still use their automatically selected helpers. + +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. + +Credits and dedicated stablecoin helpers take only records. Dynamic ARC helpers +prepend the underlying program identifier, such as `'test_arc20_eth'`, and +supply the token as a dynamic import. The Aleo SDK resolves its transitive +imports for local and delegated proving. Wallet adapters must support the +selected helper's inputs and pinned records from the underlying program. + +With management enabled, a batch reserves all inputs together. The journal +attributes the call to the source asset and retains fees and proved transactions +for recovery. Static helper outputs expose record commitments directly. For a +dynamic helper, execution matches its output id to the nested token join's +commitment. Dependent steps wait for that exact record to appear in the scanner. ## Coordinate with Shield Swap @@ -142,7 +196,7 @@ proving; delegated proving requests `broadcast: false` when checkpoints are needed. The callback persists the transaction and checks the configured fee ceiling. Custom `ProvingConfig.execute` adapters must follow this same contract. -Wallet clients need granted plaintext or record fields (`$nonce`, amount, +Wallet clients need granted plaintext or record fields (`$nonce`, the selected amount field, record name, and `recipient_bound` when applicable), plus a pinned record uid for spending without plaintext. Dependent execution also needs output commitment metadata (`commitment` or a `$commitment` grant). Rebalance checks consumed @@ -171,3 +225,7 @@ them: they do not reserve records, enforce fee limits, or enable recovery. Its `chainId` and `tokenJoin` options apply only when the client was created without record management settings. Settings supplied during client creation 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. From 36805eb8bffe68fd31945d75cadf387003d2782a Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 11:23:31 -0400 Subject: [PATCH 09/32] [Test] Verify deployed record inventory helpers --- .../recordInventory.integration.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/core/test/integration/recordInventory.integration.test.ts 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..c2e5f209 --- /dev/null +++ b/packages/core/test/integration/recordInventory.integration.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { 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)) + // Check both ends of each deployment's range through the spending validator, + // including the 10/11 and 14/15 boundaries between static helper programs. + for (const helper of helpers) { + await validateRecordJoinFunction(client, resolved.asset, helper, helper.counts[0]!) + await validateRecordJoinFunction(client, resolved.asset, helper, helper.counts.at(-1)!) + } + }, 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) +}) From c1b1aa9e14ce2f78aa783515b2dfa4ec42372010 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 12:16:50 -0400 Subject: [PATCH 10/32] [Fix] Resolve static join helper record outputs --- packages/core/src/inventory/tokenJoin.ts | 20 +++++++++-- .../test/inventory/autojoinHelpers.test.ts | 33 +++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts index fcfef3c6..663a2dbd 100644 --- a/packages/core/src/inventory/tokenJoin.ts +++ b/packages/core/src/inventory/tokenJoin.ts @@ -56,7 +56,7 @@ export async function validateTokenJoinFunction(client: Client, router: TokenJoi * @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 dynamic output cannot be matched unambiguously. + * @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 @@ -73,8 +73,22 @@ export function inventoryOutputCommitments(transaction: Transaction, asset: Reco outer.function !== (helper || routed ? `join_${step.inputs.length}` : step.kind)) { throw new Error('Confirmed transition does not match the inventory plan') } - // Native and static helper outputs expose the commitments that scanners index. - if (helper ? helper.inputMode === 'records' : !routed) return (outer.outputs ?? []).filter((output) => output.type === 'record' || output.type === 'record_with_dynamic_id').map((output) => output.id) + // 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') // A router returns a dynamic id, not a scanner commitment. Only the matching diff --git a/packages/core/test/inventory/autojoinHelpers.test.ts b/packages/core/test/inventory/autojoinHelpers.test.ts index 7f9df3cf..45673243 100644 --- a/packages/core/test/inventory/autojoinHelpers.test.ts +++ b/packages/core/test/inventory/autojoinHelpers.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' -import { createWalletClient, memoryRecordInventoryStore, type RecordAsset, type RecordManagementConfig } from '../../src/index.js' +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. @@ -51,10 +52,16 @@ function world(program = 'credits.aleo', options: { network?: 'mainnet' | 'testn 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: 'dynamicfield' }] : outputs.map(record => ({ type: 'record', id: record.commitment, value: 'record1ciphertext' })) }, + outputs: dynamic ? [{ type: 'record_dynamic', id: 'dynamicfield' }] + : external ? [{ type: 'external_record', id: 'externalfield' }] : outputs.map(record => ({ type: 'record', id: record.commitment, value: 'record1ciphertext' })) }, ] } } transactions.set(transaction.id, transaction) return transaction.id @@ -148,3 +155,25 @@ describe('automatic join helper selection', () => { 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, inputs: ['a', 'b', 'c'], outputs: [{ id: 'joined', amount: 6n }], + joinProgram: { program: 'aj_test_usdcx_stablecoin_2_10.aleo', minRecords: 2, maxRecords: 10, 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() + }) +}) From e28412b1447d15da73136a4ee59ced8c79b7ffe5 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 12:34:35 -0400 Subject: [PATCH 11/32] [Fix] Resolve dynamic join outputs from nested transitions --- packages/core/src/inventory/tokenJoin.ts | 17 ++++++---- .../test/inventory/autojoinHelpers.test.ts | 33 +++++++++++++++++-- .../core/test/inventory/inventory.test.ts | 6 ++-- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/core/src/inventory/tokenJoin.ts b/packages/core/src/inventory/tokenJoin.ts index 663a2dbd..504f8213 100644 --- a/packages/core/src/inventory/tokenJoin.ts +++ b/packages/core/src/inventory/tokenJoin.ts @@ -91,11 +91,14 @@ export function inventoryOutputCommitments(transaction: Transaction, asset: Reco 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') - // A router returns a dynamic id, not a scanner commitment. Only the matching - // nested token join exposes the encrypted record and its actual commitment. - const matches = transitions.filter((transition) => transition.program === asset.program && transition.function === 'join') - .flatMap((transition) => transition.outputs ?? []) - .filter((output) => output.type === 'record_with_dynamic_id' && output.dynamic_id === outputs[0]!.id) - if (matches.length !== 1) throw new Error('Cannot resolve confirmed router output to the underlying token commitment') - return [matches[0]!.id] + // 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/test/inventory/autojoinHelpers.test.ts b/packages/core/test/inventory/autojoinHelpers.test.ts index 45673243..ca4b81d0 100644 --- a/packages/core/test/inventory/autojoinHelpers.test.ts +++ b/packages/core/test/inventory/autojoinHelpers.test.ts @@ -60,7 +60,7 @@ function world(program = 'credits.aleo', options: { network?: 'mainnet' | 'testn })) : []), ...(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: 'dynamicfield' }] + 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) @@ -160,8 +160,8 @@ describe('automatic join helper selection', () => { // 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, inputs: ['a', 'b', 'c'], outputs: [{ id: 'joined', amount: 6n }], - joinProgram: { program: 'aj_test_usdcx_stablecoin_2_10.aleo', minRecords: 2, maxRecords: 10, inputMode: 'records' as const } } + 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 @@ -177,3 +177,30 @@ describe('confirmed static helper outputs', () => { 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/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index ab829e01..e73bb7f7 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -64,7 +64,7 @@ function world(amounts: bigint[], asset = credits, tokenJoin?: TokenJoinRouter) { 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: `dynamic${sequence}field`, type: 'record_dynamic' }] }, + 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 } @@ -447,12 +447,12 @@ describe('configured token batch joins', () => { expect(w.build).not.toHaveBeenCalled() expect(huge.build).not.toHaveBeenCalled() }) - it('rejects an unrelated or mismatched dynamic output instead of selecting intermediate records', async () => { + 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(-1).outputs[0].id = 'unrelatedfield' + 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 } }) From bc43b1068383517975de51ff239124bda80089d0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 12:47:52 -0400 Subject: [PATCH 12/32] [Test] Exercise live record inventory lifecycle and recovery --- .../integration/recordInventory.e2e.test.ts | 158 +++++++++++++++ .../integration/recordInventoryHarness.ts | 183 ++++++++++++++++++ site/docs/guides/record-inventory.md | 50 ++++- 3 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 packages/shield-swap/test/integration/recordInventory.e2e.test.ts create mode 100644 packages/shield-swap/test/integration/recordInventoryHarness.ts 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/recordInventoryHarness.ts b/packages/shield-swap/test/integration/recordInventoryHarness.ts new file mode 100644 index 00000000..42feeb45 --- /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/veil-cli/storage' +import { getConfirmedTransaction } from '@provablehq/veil-core' +import type { InventoryRecord, InventoryTarget, OwnedRecord, RecordAsset, RecordInventoryStore, 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) => 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' } } : {}), + }).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: () => connect(store), 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 26a95311..57cf9310 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -157,9 +157,10 @@ selected helper's inputs and pinned records from the underlying program. With management enabled, a batch reserves all inputs together. The journal attributes the call to the source asset and retains fees and proved transactions -for recovery. Static helper outputs expose record commitments directly. For a -dynamic helper, execution matches its output id to the nested token join's -commitment. Dependent steps wait for that exact record to appear in the scanner. +for recovery. Helper execution reads the record commitment from the final nested +join, whose output contains the encrypted record. The outer helper's external +or dynamic return id is a separate commitment. Dependent steps wait for the +nested join's exact record to appear in the scanner. ## Coordinate with Shield Swap @@ -229,3 +230,46 @@ 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. + + +## Run the integration checks + +The deployed-interface check reads the testnet credits, USDCx, and ETH programs +and their join helpers. It needs no account and submits no transactions: + +```sh +VEIL_INTEGRATION=1 pnpm vitest run packages/core/test/integration/recordInventory.integration.test.ts +``` + +The inventory lifecycle suite uses a funded testnet account to join and split +USDCx and ETH records. It checks asset defaults and overrides, shared +reservations, reviewed plans, and recovery of a prepared transaction after +reopening its SQLite journal. Both tokens finish with six balanced records. +Run it from the repository root: + +```sh +VEIL_INVENTORY_E2E=1 \ +VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ +VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ +pnpm vitest run packages/shield-swap/test/integration/recordInventory.e2e.test.ts --bail=1 +``` + +`VEIL_E2E_STATE_FILE` accepts an existing Shield Swap testnet session containing +`network: "testnet"` and `privateKey`. `VEIL_E2E_PRIVATE_KEY` can supply the key +instead. The suites use delegated proving and FeeMaster. They do not create or +fund an account. + +`VEIL_INVENTORY_RECORDS` sets the count per token (default 6, range 3 to 16). +`VEIL_INVENTORY_MIN_USDCX` defaults to `0.1` USDCx and +`VEIL_INVENTORY_MIN_ETH` to `0.000001` ETH. The account must hold at least the +minimum multiplied by `(record count + 1)` for each token. Amount settings use +human-readable token units. The tests rebalance the account's full available +inventory for both tokens; use a dedicated test account. + +The journal defaults to `.veil/inventory-e2e/testnet/
/inventory.sqlite`. +Set `VEIL_INVENTORY_E2E_DIR` to move that directory. `VEIL_BLINDED_STORE` selects +the swap recovery file; its default is `blinded.json` in the journal directory. +Run suites sequentially for a given account and retain both stores after a +failure. The next run reconciles the journal and refuses to proceed while an +operation remains unresolved. If a process was terminated, verify it has stopped +before removing its `run.lock` file. From c7ed6e6ebbda3ea17f33e96a146648067b4fbb29 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 13:10:25 -0400 Subject: [PATCH 13/32] [Test] Verify balanced inventories through repeated swaps --- .../recordInventoryStrategy.e2e.test.ts | 131 ++++++++++++++++++ site/docs/guides/record-inventory.md | 30 ++++ 2 files changed, 161 insertions(+) create mode 100644 packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 57cf9310..61136c93 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -273,3 +273,33 @@ Run suites sequentially for a given account and retain both stores after a failure. The next run reconciles the journal and refuses to proceed while an operation remains unresolved. If a process was terminated, verify it has stopped before removing its `run.lock` file. + + +### Exercise inventory during repeated swaps + +The strategy suite starts with six balanced records per token and makes three +USDCx-to-ETH-to-USDCx round trips through a live Shield Swap pool. Each forward +swap spends `0.1` USDCx by default. The return swap spends the entire claimed ETH +record, preserving the six existing ETH records. Every leg is claimed before +the next swap begins. + +After each round, the suite restores the USDCx target with a batch join followed +by splits. It checks record counts, minimum amounts, exact balance changes, and +confirmed reservations. The final check reopens the journal and verifies that +no input record was consumed by two confirmed transactions. + +Run this after the lifecycle suite, using the same account and stores: + +```sh +VEIL_INVENTORY_E2E=1 \ +VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ +VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ +pnpm vitest run packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts --bail=1 +``` + +`VEIL_INVENTORY_ROUNDS` sets the number of round trips (default 3, range 1 to 5). +`VEIL_INVENTORY_SWAP_USDCX` sets the amount spent on each forward swap in USDCx +units (default `0.1`). Each source record must remain above its configured +minimum after that spend. These swaps pay the pool's trading fees; the return +amount can differ from the forward amount. The tests use the confirmed claim +amount for accounting and stop subsequent writes if a check fails. From d032080f089696a3da7426065d31f9b422cf4a28 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 14:53:41 -0400 Subject: [PATCH 14/32] [Refactor] Move record inventory into the Shield Swap CLI --- .changeset/config.json | 3 +- .changeset/record-inventory.md | 3 +- README.md | 6 +- package.json | 3 +- packages/cli/package.json | 39 ----- packages/cli/src/index.ts | 110 ------------- packages/cli/tsconfig.json | 1 - packages/cli/tsup.config.ts | 2 - packages/provable-sdk/README.md | 3 +- .../INVENTORY.md} | 53 ++++--- packages/shield-swap-cli/README.md | 41 +++-- packages/shield-swap-cli/package.json | 10 +- .../shield-swap-cli/src/commands/inventory.ts | 144 ++++++++++++++++++ .../src/inventory}/config.ts | 0 .../src/inventory}/runner.ts | 0 packages/shield-swap-cli/src/registry.ts | 4 + packages/shield-swap-cli/src/session.ts | 2 +- .../{cli => shield-swap-cli}/src/storage.ts | 0 .../test/inventory-command.test.ts | 137 +++++++++++++++++ .../test/inventory-runner.test.ts} | 4 +- .../test/inventory-storage.test.ts} | 0 .../shield-swap-cli/test/package-smoke.mjs | 39 +++++ packages/shield-swap-cli/tsup.config.ts | 4 +- .../integration/recordInventoryHarness.ts | 2 +- pnpm-lock.yaml | 13 -- site/docs/guides/record-inventory.md | 4 +- site/docs/packages/cli.md | 31 ++-- site/docs/packages/shield-swap-cli.md | 6 + tsconfig.json | 2 +- vitest.config.ts | 2 +- 30 files changed, 440 insertions(+), 228 deletions(-) delete mode 100644 packages/cli/package.json delete mode 100644 packages/cli/src/index.ts delete mode 100644 packages/cli/tsconfig.json delete mode 100644 packages/cli/tsup.config.ts rename packages/{cli/README.md => shield-swap-cli/INVENTORY.md} (73%) create mode 100644 packages/shield-swap-cli/src/commands/inventory.ts rename packages/{cli/src => shield-swap-cli/src/inventory}/config.ts (100%) rename packages/{cli/src => shield-swap-cli/src/inventory}/runner.ts (100%) rename packages/{cli => shield-swap-cli}/src/storage.ts (100%) create mode 100644 packages/shield-swap-cli/test/inventory-command.test.ts rename packages/{cli/test/runner.test.ts => shield-swap-cli/test/inventory-runner.test.ts} (97%) rename packages/{cli/test/storage.test.ts => shield-swap-cli/test/inventory-storage.test.ts} (100%) create mode 100644 packages/shield-swap-cli/test/package-smoke.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 0def6e61..89ef4295 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,8 +13,7 @@ "@provablehq/shield-swap-sdk", "@provablehq/shield-swap-cli", "@provablehq/veil-codegen", - "@provablehq/aleo-bridge-sdk", - "@provablehq/veil-cli" + "@provablehq/aleo-bridge-sdk" ] ], "linked": [], diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index 3cdc0a7c..22c02c0c 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -3,10 +3,9 @@ "@provablehq/veil-aleo-sdk": minor "@provablehq/shield-swap-sdk": minor "@provablehq/shield-swap-cli": minor -"@provablehq/veil-cli": minor --- -Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add the separate Veil inventory CLI with bounded maintenance policies and a CLI-only SQLite recovery journal. SDK packages retain their existing runtime requirements. +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. diff --git a/README.md b/README.md index 9087310d..399b285b 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,9 @@ 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. The separate -[`@provablehq/veil-cli`](packages/cli/README.md) runs background maintenance and -stores recovery data in SQLite. +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 inventory guide](site/docs/guides/record-inventory.md) to enable them, diff --git a/package.json b/package.json index ff0b8535..5d8bf68d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "changeset": "changeset", "version-packages": "changeset version && node scripts/sync-versions.mjs", "release": "pnpm build && changeset publish", - "setup:first-swap": "node scripts/setup-first-swap.mjs", - "veil": "tsx packages/cli/src/index.ts" + "setup:first-swap": "node scripts/setup-first-swap.mjs" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index 15dff71f..00000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@provablehq/veil-cli", - "version": "0.11.1", - "description": "Record inventory management for Aleo.", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/ProvableHQ/veil.git", - "directory": "packages/cli" - }, - "type": "module", - "bin": { - "veil": "./dist/index.js" - }, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "files": [ - "dist" - ], - "engines": { - "node": ">=22.13" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@provablehq/veil-core": "workspace:^", - "@provablehq/veil-aleo-sdk": "workspace:^" - }, - "exports": { - "./storage": { - "types": "./dist/storage.d.ts", - "import": "./dist/storage.js" - } - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index e48f904c..00000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node -import { parseArgs } from 'node:util' -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 './config.js' -import { runInventoryCycle } from './runner.js' - -const HELP = `veil 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 - --help Show this help - -Amounts in configuration are decimal strings in token base units. -The private key is read from ALEO_PRIVATE_KEY or config.privateKeyEnv. -run stays in the foreground for a service manager; Ctrl-C preserves pending work. -Node 22.13 or newer is required.` - -const print = (value: unknown) => console.log(JSON.stringify(value, (_, item) => typeof item === 'bigint' ? item.toString() : item, 2)) - -async function main(): Promise { - const { values, positionals } = parseArgs({ allowPositionals: true, options: { - config: { type: 'string' }, asset: { type: 'string' }, standard: { type: 'string' }, 'record-name': { type: 'string' }, 'amount-field': { type: 'string' }, records: { type: 'string' }, - network: { type: 'string' }, database: { type: 'string' }, execute: { type: 'boolean' }, help: { type: 'boolean' }, - } }) - if (values.help || !positionals.length) { console.log(HELP); return } - const [group, command] = positionals - if (group !== 'inventory' || !command || !['inspect', 'plan', 'rebalance', 'run', 'status'].includes(command) || positionals.length !== 2) throw new Error(HELP) - // Apply explicit command-line overrides before validating the combined policy. - const raw = values.config ? JSON.parse(await readFile(values.config, 'utf8')) : {} - if (values.network) raw.network = values.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] - if (!privateKey) throw new Error(`Set ${config.privateKeyEnv} to the signing account private key`) - const aleo = await loadNetwork(config.network) - const store = await sqliteRecordInventoryStore(config.database) - // Enable the shared journal so maintenance and participating trading clients - // cannot reserve the same records. Execution still requires --execute. - 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 } }) - const scope = JSON.stringify([config.chainId, account.address]) - let lease: string | undefined - const controller = new AbortController() - const stop = () => controller.abort() - process.once('SIGINT', stop) - process.once('SIGTERM', stop) - try { - if (command === 'status') { - // Print operation metadata without the stored proved transaction payload. - print((await store.list(scope)).map(({ transaction: _transaction, ...entry }) => entry)); return - } - if (command === 'inspect') { - for (const policy of config.policies) { - const inventory = await client.getRecordInventory({ asset: policy.asset }) - print({ asset: policy.asset, balance: inventory.balance, - available: inventory.available.map(({ id, amount }) => ({ id, amount })), - reserved: inventory.reserved.map(({ id, amount }) => ({ id, amount })) }) - } - return - } - // All write commands stop at planning unless --execute was supplied. - if (command === 'plan' || !values.execute) { - for (const policy of config.policies) print(await client.planRecordInventory({ ...policy, maxTransactions: config.maxTransactions })) - return - } - // Reserve a synthetic record id to allow only one maintenance loop per - // account and chain, while ordinary trading uses the real 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 - do { - try { - const results = await runInventoryCycle(client, config, store, scope, controller.signal) - // Background runs emit only actionable results; stable inventory stays quiet. - if (command !== 'run' || results.some((result) => !['satisfied', 'cooldown'].includes(result.status))) print(results) - if (command !== 'run' && results.some((result) => ['interrupted', 'unavailable', 'waiting', 'budget'].includes(result.status))) process.exitCode = 1 - } catch (error) { - if (command !== 'run') throw error - console.error(error instanceof Error ? error.message : String(error)) - } - 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) - } finally { - // Release only the manager lease on shutdown; pending transaction entries - // remain in the journal for the next process to reconcile. - try { if (lease) await store.update(scope, lease, { status: 'cancelled' }) } - finally { - store.close() - process.removeListener('SIGINT', stop) - process.removeListener('SIGTERM', stop) - } - } -} - -main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index 9379bff0..00000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": ".." }, "include": ["src"] } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts deleted file mode 100644 index 01817d80..00000000 --- a/packages/cli/tsup.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineConfig } from 'tsup' -export default defineConfig({ entry: ['src/index.ts', 'src/storage.ts'], format: ['esm'], dts: true, clean: true, sourcemap: true }) diff --git a/packages/provable-sdk/README.md b/packages/provable-sdk/README.md index 322eb377..24357c68 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -175,7 +175,8 @@ catch up before planning inventory changes. `syncTimeoutMs` defaults to 300000 milliseconds; `waitForSync` defaults to false. Local and delegated execution both await the `transaction-prepared` callback before submission, so an application can save the proved transaction for recovery. The application -supplies storage; SQLite is available only through the separate Veil CLI. See +supplies storage; SQLite is available through +`@provablehq/shield-swap-cli/storage` (Node 22.13+). See the [inventory guide](../../site/docs/guides/record-inventory.md). Dynamic ARC join helpers supply the underlying token as a dynamic import; diff --git a/packages/cli/README.md b/packages/shield-swap-cli/INVENTORY.md similarity index 73% rename from packages/cli/README.md rename to packages/shield-swap-cli/INVENTORY.md index 4fa34846..8c44cb15 100644 --- a/packages/cli/README.md +++ b/packages/shield-swap-cli/INVENTORY.md @@ -1,27 +1,38 @@ -# @provablehq/veil-cli +# Record inventory management -Run scanner-backed record inventory management for native `credits.aleo`, ARC20, -ARC22, and compatible custom records. Requires Node **22.13+** for built-in SQLite. SDK consumers do -not install this application or SQLite. +`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/veil-cli`, then supply the signing key through -`ALEO_PRIVATE_KEY` using the environment or a service manager's secret facility. -Amounts are integer token base units; credits use microcredits. +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 -veil inventory inspect --asset credits.aleo -veil inventory plan --asset credits.aleo --records 4 -veil inventory rebalance --asset credits.aleo --records 4 --execute -veil inventory run --config inventory.json --execute -veil inventory status --config inventory.json +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 veil inventory ...`. +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 @@ -48,7 +59,10 @@ Within this repository, use `pnpm veil inventory ...`. 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. Set +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. @@ -82,7 +96,7 @@ credits to `credits.microcredits`. Custom records use `amount`, then `microcredits`, unless a field is supplied. For a custom record: ```sh -veil inventory plan --asset points.aleo --record-name Balance --amount-field points --records 4 +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", @@ -122,7 +136,7 @@ open the same file and enable record coordination when creating the client: ```ts import { loadNetwork } from '@provablehq/veil-aleo-sdk' -import { sqliteRecordInventoryStore } from '@provablehq/veil-cli/storage' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' const store = await sqliteRecordInventoryStore('.veil/inventory.sqlite') const aleo = await loadNetwork('testnet') @@ -136,9 +150,10 @@ const { walletClient } = aleo.createAleoClient({ // Close the store when the application shuts down. ``` -For the Shield Swap CLI, set `VEIL_INVENTORY_DB` to this database path and -select the same network. This option requires Node 22.13+. Record management is -off by default, including on clients extended with `recordActions()`. +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 diff --git a/packages/shield-swap-cli/README.md b/packages/shield-swap-cli/README.md index f3298ad7..f5df47fc 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 | @@ -233,14 +237,33 @@ 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. -## Share record reservations with inventory maintenance +## 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. -Set `VEIL_INVENTORY_DB` to the journal path used by `veil inventory run` so -trading and maintenance cannot reserve the same explicit record inputs. The -session enables `recordManagement: { enabled: true, store, chainId }` when it -creates the client. This option loads `@provablehq/veil-cli/storage` and requires -Node 22.13+. +```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 +``` -Without the environment variable, the CLI keeps record coordination disabled -and opens no inventory journal. Record methods alone do not enable it. See the -[Veil CLI reference](../cli/README.md) for policy configuration and recovery. +`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`. + +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 62e71621..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" @@ -51,8 +56,5 @@ ], "bugs": { "url": "https://github.com/ProvableHQ/veil/issues" - }, - "optionalDependencies": { - "@provablehq/veil-cli": "workspace:^" } } 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/cli/src/config.ts b/packages/shield-swap-cli/src/inventory/config.ts similarity index 100% rename from packages/cli/src/config.ts rename to packages/shield-swap-cli/src/inventory/config.ts diff --git a/packages/cli/src/runner.ts b/packages/shield-swap-cli/src/inventory/runner.ts similarity index 100% rename from packages/cli/src/runner.ts rename to packages/shield-swap-cli/src/inventory/runner.ts 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 6b6515dc..fee65421 100644 --- a/packages/shield-swap-cli/src/session.ts +++ b/packages/shield-swap-cli/src/session.ts @@ -245,7 +245,7 @@ export async function loadSession(options: { network?: string } = {}) { // 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('@provablehq/veil-cli/storage')).sqliteRecordInventoryStore(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. diff --git a/packages/cli/src/storage.ts b/packages/shield-swap-cli/src/storage.ts similarity index 100% rename from packages/cli/src/storage.ts rename to packages/shield-swap-cli/src/storage.ts 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..f17de3fd --- /dev/null +++ b/packages/shield-swap-cli/test/inventory-command.test.ts @@ -0,0 +1,137 @@ +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', 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' }) + 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/cli/test/runner.test.ts b/packages/shield-swap-cli/test/inventory-runner.test.ts similarity index 97% rename from packages/cli/test/runner.test.ts rename to packages/shield-swap-cli/test/inventory-runner.test.ts index 07a09c30..90006a38 100644 --- a/packages/cli/test/runner.test.ts +++ b/packages/shield-swap-cli/test/inventory-runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { parseInventoryConfig } from '../src/config.js' -import { runInventoryCycle } from '../src/runner.js' +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() })) diff --git a/packages/cli/test/storage.test.ts b/packages/shield-swap-cli/test/inventory-storage.test.ts similarity index 100% rename from packages/cli/test/storage.test.ts rename to packages/shield-swap-cli/test/inventory-storage.test.ts 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/test/integration/recordInventoryHarness.ts b/packages/shield-swap/test/integration/recordInventoryHarness.ts index 42feeb45..9d27f19a 100644 --- a/packages/shield-swap/test/integration/recordInventoryHarness.ts +++ b/packages/shield-swap/test/integration/recordInventoryHarness.ts @@ -2,7 +2,7 @@ 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/veil-cli/storage' +import { sqliteRecordInventoryStore } from '@provablehq/shield-swap-cli/storage' import { getConfirmedTransaction } from '@provablehq/veil-core' import type { InventoryRecord, InventoryTarget, OwnedRecord, RecordAsset, RecordInventoryStore, Transaction } from '@provablehq/veil-core' import { shieldSwapActions, tokenData, parseUnits, formatUnits, type TokenInfo } from '../../src/index.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddf7dd8e..2979e4a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,15 +126,6 @@ importers: specifier: ^4.21.0 version: 4.21.0 - packages/cli: - dependencies: - '@provablehq/veil-aleo-sdk': - specifier: workspace:^ - version: link:../provable-sdk - '@provablehq/veil-core': - specifier: workspace:^ - version: link:../core - packages/codegen: dependencies: '@provablehq/veil-core': @@ -263,10 +254,6 @@ importers: '@provablehq/veil-core': specifier: workspace:^ version: link:../core - optionalDependencies: - '@provablehq/veil-cli': - specifier: workspace:^ - version: link:../cli packages/wallet-adapter: dependencies: diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 61136c93..3ff2107a 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -48,7 +48,7 @@ An enabled client uses an in-memory store unless the caller supplies one. Disabled clients create no store. To recover unfinished operations after a restart, long-running services MUST supply a durable `RecordInventoryStore` through `recordManagement.store`. SQLite storage is available only from the -separate `@provablehq/veil-cli/storage` application package. +`@provablehq/shield-swap-cli/storage` entry point (Node 22.13+). | Action | Behavior | | --- | --- | @@ -211,7 +211,7 @@ Private fee selection is rejected on managed writes because its inputs are selected outside this reservation mechanism. Use public fees or FeeMaster. For a supervised background process with policies, cooldowns, budgets, and a -restart journal, use the [inventory CLI](../packages/cli.md). +restart journal, use the [Shield Swap inventory commands](../packages/cli.md). ## Migrate a recordActions configuration diff --git a/site/docs/packages/cli.md b/site/docs/packages/cli.md index ea4b1245..91da7570 100644 --- a/site/docs/packages/cli.md +++ b/site/docs/packages/cli.md @@ -1,18 +1,25 @@ -# Veil CLI +# Shield Swap inventory -`@provablehq/veil-cli` supplies the `veil inventory` application for scanner-backed -ARC20, ARC22, and native credits record maintenance. Requires Node 22.13+; the -SDK packages retain their existing runtime support. +`shield-swap inventory` manages spendable records for ARC20, ARC22, native +credits, and compatible custom assets. It ships in `@provablehq/shield-swap-cli` +and requires Node 22.13+ for its SQLite journal. SDK packages retain their +existing runtime support. ```sh -veil inventory plan --asset credits.aleo --records 4 -veil inventory run --config inventory.json --execute +shield-swap inventory plan --asset credits.aleo --records 4 +shield-swap inventory run --config inventory.json --execute ``` -Commands are `inspect`, `plan`, `rebalance`, `run`, and `status`. Writes require -`--execute`; otherwise the application prints plans. Run the foreground loop -under a service manager for background operation. Its SQLite journal belongs to -the CLI package; core exposes an injectable store interface and in-memory default. +The commands are `inspect`, `plan`, `rebalance`, `run`, and `status`. Inventory +uses the saved Shield Swap account or a key supplied through the environment; +it does not require DEX authentication. Writes require `--execute`. Without it, +`rebalance` and `run` print plans. -See the [CLI configuration and recovery reference](https://github.com/ProvableHQ/veil/blob/main/packages/cli/README.md) -and [inventory SDK guide](../guides/record-inventory.md). +Run the foreground loop under a service manager for background operation. +`--json` returns one object; a continuous `run --execute --json` emits its +summary on shutdown. Set `VEIL_INVENTORY_DB` on participating trading commands +to share reservations with maintenance. Trading coordination remains off unless +that variable is set. + +See the [configuration and recovery reference](https://github.com/ProvableHQ/veil/blob/main/packages/shield-swap-cli/INVENTORY.md), +[Shield Swap CLI](./shield-swap-cli.md), and [inventory SDK guide](../guides/record-inventory.md). diff --git a/site/docs/packages/shield-swap-cli.md b/site/docs/packages/shield-swap-cli.md index 8d9b49b9..86d8b70b 100644 --- a/site/docs/packages/shield-swap-cli.md +++ b/site/docs/packages/shield-swap-cli.md @@ -25,3 +25,9 @@ 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 + +Use `shield-swap inventory` to inspect, plan, rebalance, and maintain spendable +records. It uses the configured account and requires Node 22.13+. See the +[inventory commands](./cli.md) for configuration, shared reservations, and recovery. diff --git a/tsconfig.json b/tsconfig.json index eb30a2a9..18883866 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "isolatedModules": true, "noUncheckedIndexedAccess": true, "paths": { - "@provablehq/veil-cli/storage": ["./packages/cli/src/storage.ts"], + "@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 21ec6ed5..48408ad1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ }, resolve: { alias: { - '@provablehq/veil-cli/storage': path.resolve(__dirname, 'packages/cli/src/storage.ts'), + '@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'), From 61724455ef448a7936650470a00672a27ee9e56e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 17:42:17 -0400 Subject: [PATCH 15/32] [Feat] Coordinate private fee record selection --- .changeset/record-inventory.md | 2 + apps/loyalty-dapp/src/lib/aleo.ts | 2 + examples/e2e-demo.ts | 3 + packages/core/README.md | 6 + .../src/actions/wallet/executeContract.ts | 11 +- .../core/src/actions/wallet/writeContract.ts | 11 +- packages/core/src/index.ts | 1 + packages/core/src/inventory/internal.ts | 4 +- packages/core/src/inventory/spending.ts | 70 ++++++++-- packages/core/src/inventory/store.ts | 10 +- packages/core/src/inventory/types.ts | 5 +- packages/core/src/types/proving.ts | 28 +++- .../core/test/inventory/configuration.test.ts | 2 +- .../core/test/inventory/inventory.test.ts | 4 +- .../core/test/inventory/privateFees.test.ts | 121 ++++++++++++++++++ packages/provable-sdk/README.md | 7 + packages/provable-sdk/src/index.ts | 42 +++++- .../test/integration/privateFees.e2e.test.ts | 102 +++++++++++++++ .../provable-sdk/test/privateFees.test.ts | 40 ++++++ packages/shield-swap-cli/INVENTORY.md | 11 +- packages/shield-swap-cli/src/storage.ts | 6 +- .../test/inventory-storage.test.ts | 12 ++ site/docs/guides/record-inventory.md | 36 +++++- 23 files changed, 496 insertions(+), 40 deletions(-) create mode 100644 packages/core/test/inventory/privateFees.test.ts create mode 100644 packages/provable-sdk/test/integration/privateFees.e2e.test.ts create mode 100644 packages/provable-sdk/test/privateFees.test.ts diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index 22c02c0c..932a86cf 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -10,3 +10,5 @@ Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared re 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. diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 517bc34b..59cd049e 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -35,6 +35,8 @@ export function createAleoWalletClient(adapter: AleoWalletAdapter) { // 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' }, }) } diff --git a/examples/e2e-demo.ts b/examples/e2e-demo.ts index 5de27e47..142c2089 100644 --- a/examples/e2e-demo.ts +++ b/examples/e2e-demo.ts @@ -239,6 +239,9 @@ describe('E2E: veil against live Aleo mainnet', () => { // 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. describe('Record inventory consumer example', () => { it('plans four credits records while keeping the source record private', async () => { const aleo = await loadNetwork('testnet') diff --git a/packages/core/README.md b/packages/core/README.md index 4c9ea74a..ac36a9c8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -260,6 +260,12 @@ to `createWalletClient` or `createClient`. An enabled client creates an in-memor store if none is supplied. Disabled clients create no store. Core has no SQLite or Node dependency. +With a local signer, `writeContract` and `executeContract` accept `privateFee: true` +and an optional `feeRecord` plaintext. Managed writes replace a reserved or +unavailable fee record with a sufficient unreserved credits record, excluding +execution inputs. No eligible record means an error before proving. Wallet RPC +writes cannot use managed private fees until the wallet can pin the fee record. + An asset needs only its program: `{ program: 'credits.aleo' }`. Optional `recordName` and `amountField` select a custom record balance. Core reads the program's join/split interface and defaults to `amount`, then `microcredits`; diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index c1d4eac4..7938b6f2 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -22,10 +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. @@ -36,6 +38,7 @@ export type ExecuteContractParameters = { inputs: TransactionInput[] fee?: bigint privateFee?: boolean + feeRecord?: string programSource?: string imports?: Record onProgress?: ProvingProgressHandler @@ -84,8 +87,8 @@ export async function executeContract( // 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) => - executeContract(withoutManagement(client), { ...snapshot, onProgress })) + return manageRecordSpend(client, snapshot, (onProgress, feeRecord) => + executeContract(withoutManagement(client, feeRecord), { ...snapshot, onProgress })) } const account = client.account if (!account || !('sign' in account)) { @@ -93,6 +96,7 @@ export async function executeContract( } 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. @@ -133,6 +137,7 @@ 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 fcc59abc..fddc9914 100644 --- a/packages/core/src/actions/wallet/writeContract.ts +++ b/packages/core/src/actions/wallet/writeContract.ts @@ -16,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. @@ -27,6 +29,7 @@ export type WriteContractParameters = { function: string inputs: TransactionInput[] privateFee?: boolean + feeRecord?: string imports?: string[] onProgress?: ProvingProgressHandler | undefined } @@ -73,8 +76,8 @@ export async function writeContract( // 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) => - writeContract(withoutManagement(client), { ...snapshot, onProgress })) + return manageRecordSpend(client, snapshot, (onProgress, feeRecord) => + writeContract(withoutManagement(client, feeRecord), { ...snapshot, onProgress })) } const account = client.account if (!account || !('sign' in account)) { @@ -82,6 +85,7 @@ export async function writeContract( } 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', @@ -111,6 +115,7 @@ 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 } : {}), }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 786678e1..099ab44c 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, diff --git a/packages/core/src/inventory/internal.ts b/packages/core/src/inventory/internal.ts index c0650a8a..fc834468 100644 --- a/packages/core/src/inventory/internal.ts +++ b/packages/core/src/inventory/internal.ts @@ -2,7 +2,7 @@ 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 { holdsRecords, memoryRecordInventoryStore } from './store.js' +import { heldRecordIds, memoryRecordInventoryStore } from './store.js' import { validateTokenJoinRouter } from './tokenJoin.js' import type { Transport } from '../types/transport.js' @@ -99,7 +99,7 @@ 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))).filter(holdsRecords).flatMap((entry) => entry.records)) + return new Set((await config.store.list(scopeOf(client))).flatMap(heldRecordIds)) } /** Enforces positive bounded integers; the default upper bound is 1000. @internal */ diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts index cb6cc960..7b55e5c0 100644 --- a/packages/core/src/inventory/spending.ts +++ b/packages/core/src/inventory/spending.ts @@ -1,24 +1,32 @@ 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, scopeOf } from './internal.js' /** Builds an internal client view that avoids recursive reservation acquisition. @internal */ -export function withoutManagement(client: Client): Client { +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. - return Object.assign(Object.create(client), { recordManagement: undefined }) as Client + 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 the public base and priority fees in microcredits; rejects an unverifiable fee. @internal */ +/** 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 - // Public fee inputs carry the base and priority fees. An opaque or malformed - // fee cannot be compared safely with a pre-broadcast spending limit. - const values = tx.fee.transition.inputs?.slice(0, 2).map((input) => input.value) + // 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') } @@ -27,12 +35,14 @@ export function transactionFee(tx: Transaction): bigint { /** 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 + program: string; function: string; inputs: TransactionInput[]; privateFee?: boolean; feeRecord?: string; fee?: bigint; programSource?: string imports?: string[] | Record onProgress?: ProvingProgressHandler -}, submit: (onProgress: ProvingProgressHandler) => Promise): Promise { +}, submit: (onProgress: ProvingProgressHandler, feeRecord?: string) => Promise): Promise { const config = management(client)! - if (params.privateFee) throw new Error('Managed records require public fees or FeeMaster; private fee selection is not coordinated') + 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 @@ -60,9 +70,37 @@ export async function manageRecordSpend(client: Client, params: { // 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 - // Acquire all input nonces together before invoking any proving or wallet call. - const entry = await config.store.acquire({ scope, records: ids, program: params.program, ...(assetProgram ? { assetProgram } : {}), + 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 } + } + 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 Error('Record inventory changed: an input is already reserved or spent') let prepared = false let submitted = false @@ -73,7 +111,15 @@ export async function manageRecordSpend(client: Client, params: { 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') } @@ -90,7 +136,7 @@ export async function manageRecordSpend(client: Client, params: { } // 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. diff --git a/packages/core/src/inventory/store.ts b/packages/core/src/inventory/store.ts index 8d616232..7c1b9c7b 100644 --- a/packages/core/src/inventory/store.ts +++ b/packages/core/src/inventory/store.ts @@ -18,7 +18,7 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { // 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) && - old.records.some((id) => entry.records.includes(id)))) return undefined + 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) @@ -37,5 +37,11 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { /** 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' + 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/types.ts b/packages/core/src/inventory/types.ts index 1df8699b..6b178e0e 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -124,7 +124,8 @@ export type RecordSpendStatus = 'reserved' | 'prepared' | 'submitted' | 'confirm * 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. + * @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. @@ -139,6 +140,7 @@ export type RecordSpend = { id: string scope: string records: string[] + feeRecordId?: string program: string function: string assetProgram?: string @@ -154,6 +156,7 @@ export type RecordSpend = { * 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 for an existing operation. diff --git a/packages/core/src/types/proving.ts b/packages/core/src/types/proving.ts index 916e6e3f..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,6 +82,8 @@ 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 @@ -94,6 +98,7 @@ export type ExecuteOptions = { /** Priority fee in microcredits (1 credit = 1_000_000 microcredits) */ fee: bigint privateFee?: boolean | undefined + feeRecord?: string | undefined programSource?: string | undefined programImports?: Record | undefined } @@ -141,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. * @@ -152,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. */ @@ -160,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/inventory/configuration.test.ts b/packages/core/test/inventory/configuration.test.ts index eed5bb40..6a2b3432 100644 --- a/packages/core/test/inventory/configuration.test.ts +++ b/packages/core/test/inventory/configuration.test.ts @@ -98,7 +98,7 @@ describe('explicit record management configuration', () => { 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('public fees') + 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') }) diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index e73bb7f7..8efafbc3 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -330,7 +330,7 @@ describe('inventory execution and recovery', () => { 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 private fees', async () => { + 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 })) } @@ -339,7 +339,7 @@ describe('inventory execution and recovery', () => { 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('public fees') + await expect(w.client.writeContract({ program: credits.program, function: 'join', inputs: [], privateFee: true })).rejects.toThrow('estimateExecutionFee') expect(w.build).not.toHaveBeenCalled() }) }) diff --git a/packages/core/test/inventory/privateFees.test.ts b/packages/core/test/inventory/privateFees.test.ts new file mode 100644 index 00000000..54324979 --- /dev/null +++ b/packages/core/test/inventory/privateFees.test.ts @@ -0,0 +1,121 @@ +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('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/provable-sdk/README.md b/packages/provable-sdk/README.md index 24357c68..4a7652b4 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -170,6 +170,13 @@ transactions. `createDevnodeClient` accepts the same option; use a distinct `chainId` for each local chain. Omission or `enabled: false` leaves ordinary writes unchanged. +Managed private fees select a credits record from the configured scanner. +`writeContract` and `executeContract` accept `privateFee: true` and optional +`feeRecord` plaintext. A reserved supplied record is replaced with an available +one that covers the estimated fee; no eligible record means an error. Local, +delegated, and devnode proving use the selected record. A pinned private fee +also overrides FeeMaster sponsorship for that call. + Use `createRemoteScanner({ waitForSync: true })` to wait for the scanner to catch up before planning inventory changes. `syncTimeoutMs` defaults to 300000 milliseconds; `waitForSync` defaults to false. Local and delegated diff --git a/packages/provable-sdk/src/index.ts b/packages/provable-sdk/src/index.ts index d4dcf035..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 { RecordManagementConfig, 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' @@ -515,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. @@ -655,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, @@ -705,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' }) @@ -762,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 } : {}), }) @@ -878,10 +907,11 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo inputs: execOptions.inputs, priorityFee, privateFee: execOptions.privateFee ?? 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: options.useFeeMaster ?? false, + useFeeMaster: execOptions.feeRecord ? false : options.useFeeMaster ?? false, }) const dpsClient = new AleoNetworkClient(proverUrl) @@ -961,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, }) @@ -1619,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) @@ -1628,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 } : {}), }) @@ -1635,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) @@ -1675,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()) @@ -1685,6 +1720,7 @@ export function createDevnodeClient(options?: { functionName: execOptions.functionName, inputs: execOptions.inputs, privateFee: execOptions.privateFee, + ...(execOptions.feeRecord ? { feeRecord: execOptions.feeRecord } : {}), imports: execOptions.programImports, }) 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/shield-swap-cli/INVENTORY.md b/packages/shield-swap-cli/INVENTORY.md index 8c44cb15..2cb6b54f 100644 --- a/packages/shield-swap-cli/INVENTORY.md +++ b/packages/shield-swap-cli/INVENTORY.md @@ -120,7 +120,8 @@ 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. -A timeout or 404 does not release inputs. Recovery can resubmit the saved +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. @@ -157,9 +158,11 @@ 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 writes -require public fees or FeeMaster because private fee selection can spend records -outside the shared reservations. +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 diff --git a/packages/shield-swap-cli/src/storage.ts b/packages/shield-swap-cli/src/storage.ts index 01e07e29..44b3864d 100644 --- a/packages/shield-swap-cli/src/storage.ts +++ b/packages/shield-swap-cli/src/storage.ts @@ -76,8 +76,10 @@ export async function sqliteRecordInventoryStore(path: string): Promise old.status !== 'cancelled' && old.status !== 'rejected' && - old.records.some((id) => entry.records.includes(id)))) return undefined + // 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) } diff --git a/packages/shield-swap-cli/test/inventory-storage.test.ts b/packages/shield-swap-cli/test/inventory-storage.test.ts index 64534b94..712d82a0 100644 --- a/packages/shield-swap-cli/test/inventory-storage.test.ts +++ b/packages/shield-swap-cli/test/inventory-storage.test.ts @@ -50,6 +50,18 @@ describe('CLI-only durable inventory store', () => { 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('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' })})`) diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 3ff2107a..e801a690 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -207,8 +207,40 @@ Opaque wallet submission cannot enforce a fee ceiling before broadcast, so wallet execution rejects configured fee budgets. A lost wallet response without a transaction id retains its reservation for external reconciliation. -Private fee selection is rejected on managed writes because its inputs are -selected outside this reservation mechanism. Use public fees or FeeMaster. +### Private fees + +Managed local-account writes support `privateFee: true`. If `feeRecord` is +supplied, the client uses it when the current scan reports it as available and +it covers the estimated base fee plus priority fee. Otherwise the client selects +the smallest sufficient credits record. Selection excludes existing reservations +and the transaction's own inputs. If none is available, the call fails before +proving; it does not automatically join records or change the fee payment mode. + +```ts +await client.writeContract({ + program: 'credits.aleo', + function: 'transfer_public', + inputs: [recipient, '1000000u64'], + privateFee: true, + // Optional: feeRecord: preferredCreditsRecord.recordPlaintext, +}) +``` + +The fee record and execution inputs are reserved together. Local, delegated, +and devnode adapters receive the selected plaintext through `feeRecord`; the +journal stores only its nonce, alongside the encrypted proved transaction. +Before broadcast, the manager checks the fee transition's record tag and fee +amounts. Explicit private payment takes precedence over FeeMaster sponsorship. + +On a rejected execution, Aleo can still charge the private fee. The journal +releases the execution inputs but keeps `feeRecordId` excluded from stale scans. +Custom stores MUST preserve that exclusion for rejected entries. A timeout or +missing confirmation retains every reservation until reconciliation resolves it. + +Custom local provers need `estimateExecutionFee`, must honor `feeRecord`, and +must await the prepared-transaction callback before broadcasting. Wallet RPC +writes still require public fees or FeeMaster: the current wallet protocol +cannot pin a selected fee record. For a supervised background process with policies, cooldowns, budgets, and a restart journal, use the [Shield Swap inventory commands](../packages/cli.md). From 2f168a6f37c6c380308c1de2af612f690f7e3c9b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 07:07:47 -0400 Subject: [PATCH 16/32] [Fix] Reselect records for concurrent swaps --- .changeset/record-inventory.md | 2 + apps/loyalty-dapp/src/lib/aleo.ts | 2 + examples/e2e-demo.ts | 3 + .../src/actions/wallet/executeContract.ts | 1 + .../core/src/actions/wallet/writeContract.ts | 1 + packages/core/src/errors/errors.ts | 20 ++ packages/core/src/index.ts | 1 + packages/core/src/inventory/spending.ts | 16 +- .../core/test/inventory/privateFees.test.ts | 14 ++ packages/shield-swap-cli/README.md | 6 +- .../src/commands/swap-concurrent.ts | 17 +- .../fixtures/concurrentInventoryWorker.ts | 39 ++++ .../test/inventory-storage.test.ts | 32 ++- packages/shield-swap/README.md | 52 ++--- packages/shield-swap/skills/SKILL.md | 11 +- packages/shield-swap/skills/swapping.md | 38 ++-- .../shield-swap/src/actions/swap/internal.ts | 56 ++++- packages/shield-swap/src/actions/swap/swap.ts | 45 ++-- .../src/actions/swap/swapMultiHop.ts | 45 ++-- packages/shield-swap/src/utils/records.ts | 13 +- .../test/actions/swap/concurrentSwaps.test.ts | 212 ++++++++++++++++++ site/docs/guides/record-inventory.md | 22 ++ 22 files changed, 538 insertions(+), 110 deletions(-) create mode 100644 packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts create mode 100644 packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index 932a86cf..e5693730 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -12,3 +12,5 @@ Allow inventory assets to specify only a program, with optional record-name and 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. diff --git a/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 59cd049e..954d7ae1 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -68,6 +68,8 @@ export async function getLoyaltyContract(walletClient?: ReturnType { // 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('plans four credits records while keeping the source record private', async () => { const aleo = await loadNetwork('testnet') diff --git a/packages/core/src/actions/wallet/executeContract.ts b/packages/core/src/actions/wallet/executeContract.ts index 7938b6f2..1363fee2 100644 --- a/packages/core/src/actions/wallet/executeContract.ts +++ b/packages/core/src/actions/wallet/executeContract.ts @@ -68,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. diff --git a/packages/core/src/actions/wallet/writeContract.ts b/packages/core/src/actions/wallet/writeContract.ts index fddc9914..40230d83 100644 --- a/packages/core/src/actions/wallet/writeContract.ts +++ b/packages/core/src/actions/wallet/writeContract.ts @@ -57,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. 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 099ab44c..ee76191e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,7 @@ export { InvalidTransactionError, DuplicateTransactionError, RecordSpentError, + RecordReservationConflictError, OutputIdCollisionError, BroadcastError, TransactionTimeoutError, diff --git a/packages/core/src/inventory/spending.ts b/packages/core/src/inventory/spending.ts index 7b55e5c0..5985869f 100644 --- a/packages/core/src/inventory/spending.ts +++ b/packages/core/src/inventory/spending.ts @@ -1,3 +1,4 @@ +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' @@ -7,7 +8,7 @@ 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, scopeOf } from './internal.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 { @@ -96,12 +97,18 @@ export async function manageRecordSpend(client: Client, params: { 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 Error('Record inventory changed: an input is already reserved or spent') + if (!entry) throw new RecordReservationConflictError(ids) let prepared = false let submitted = false try { @@ -143,6 +150,11 @@ export async function manageRecordSpend(client: Client, params: { 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/test/inventory/privateFees.test.ts b/packages/core/test/inventory/privateFees.test.ts index 54324979..2c549f4f 100644 --- a/packages/core/test/inventory/privateFees.test.ts +++ b/packages/core/test/inventory/privateFees.test.ts @@ -57,6 +57,20 @@ describe('managed private fees', () => { 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) diff --git a/packages/shield-swap-cli/README.md b/packages/shield-swap-cli/README.md index f5df47fc..4d5a7da7 100644 --- a/packages/shield-swap-cli/README.md +++ b/packages/shield-swap-cli/README.md @@ -261,7 +261,11 @@ 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`. +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 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/test/fixtures/concurrentInventoryWorker.ts b/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts new file mode 100644 index 00000000..f76f79e5 --- /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 () => '', signMessage: async () => '' }, + 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, + 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-storage.test.ts b/packages/shield-swap-cli/test/inventory-storage.test.ts index 712d82a0..6843f2cd 100644 --- a/packages/shield-swap-cli/test/inventory-storage.test.ts +++ b/packages/shield-swap-cli/test/inventory-storage.test.ts @@ -2,7 +2,7 @@ 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 } from 'node:child_process' +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' @@ -37,6 +37,36 @@ describe('CLI-only durable inventory store', () => { 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)})`) diff --git a/packages/shield-swap/README.md b/packages/shield-swap/README.md index aee7728d..1a33bbd3 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -560,45 +560,47 @@ 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. - -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: +Concurrent swaps need separate input records and separate blinded identities. +Enable `recordManagement: { enabled: true, store, chainId }` when creating the +wallet client to coordinate records. If two automatic selections choose the same +record, `swap` and `swapMultiHop` retry with another eligible record before +proving. Candidates are tried from smallest to largest, once per nonce. Explicit +`tokenRecord` inputs remain fixed, and execution errors are not retried. + +Each swap also needs a single-use blinded identity. `shieldSwapActions` supplies +an in-memory identity store by default, shared by calls through that client. Use +a durable store to retain identities and claim handles after a restart: ```ts import { swapFileStore } from '@provablehq/shield-swap-sdk/node' +// walletClient was created with recordManagement.enabled: true. const client = walletClient.extend( shieldSwapActions({ api: {}, blindedIdentities: swapFileStore('.veil/blinded.json') }), ) -// Nothing else to do — these two cannot collide on an identity. -const [a, b] = await Promise.all([ +// Requires at least two available USDCx records, each covering amountIn. +const results = await Promise.allSettled([ + client.swap({ poolKey: poolA, tokenInId: usdc, amountIn, imports }), client.swap({ poolKey: poolA, tokenInId: usdc, amountIn, imports }), - client.swap({ poolKey: poolB, tokenInId: eth, amountIn, imports }), ]) +// Claim every fulfilled result, even if another swap failed. ``` -`swapFileStore` is an alias of `fileBlindedIdentityStore`; the existing export -and saved file format remain supported. +`swapFileStore` is an alias of `fileBlindedIdentityStore`. Each swap reserves its +identity before submission and saves its handle after confirmation; +`claimSwapOutput` marks it claimed. Concurrent clients in one process MUST share +the same identity store instance as well as the record store and chain identity. -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. +The bundled identity stores do not coordinate counters across processes. SQLite +can coordinate record reservations across processes, but a shared identity file +alone cannot prevent two processes from deriving the same blinded address. +Use distinct explicit identities when managing concurrent swaps across processes. -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. +Record coordination remains off by default. Without it, callers MUST supply +disjoint input records or trade sequentially. See the +[record inventory guide](../../site/docs/guides/record-inventory.md#concurrent-swaps) +for retry boundaries and storage configuration. 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 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/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/utils/records.ts b/packages/shield-swap/src/utils/records.ts index 54570c50..dbe21f04 100644 --- a/packages/shield-swap/src/utils/records.ts +++ b/packages/shield-swap/src/utils/records.ts @@ -99,6 +99,11 @@ 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', @@ -107,7 +112,7 @@ export async function selectTokenRecord(client: Client, params: SelectTokenRecor // Exclude inputs held by participating inventory or trading operations even // if the scanner still reports those records as unspent. const reserved = await reservedRecordIds(client) - let best: TokenRecordInfo | undefined + const candidates: TokenRecordInfo[] = [] for (const record of records) { if (!record.recordPlaintext) continue if (reserved.size && reserved.has(inventoryRecordId(record))) continue @@ -118,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/swap/concurrentSwaps.test.ts b/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts new file mode 100644 index 00000000..0767cd7b --- /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, 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index e801a690..e58e56a6 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -181,6 +181,28 @@ Independent clients MUST share the same store and chain identity to avoid spending each other's reserved inputs. Raw submissions and wallets outside this configuration bypass the reservations. +### Concurrent swaps + +With coordination enabled, local `swap` and `swapMultiHop` calls can sell the +same token concurrently when enough separate records cover the trades. Automatic +selection scans once and tries eligible records from smallest to largest. If +another operation reserves a candidate first, the swap tries the next one. Each +record nonce is tried at most once; exhaustion fails before proving. + +Retries preserve the amount, slippage floor, blinded identity and nonce. The +client checks the account, network and quote expiry before each attempt. +Explicit `tokenRecord` inputs stay fixed. Proving, submission and confirmation +errors are never retried by record selection. Core reports failed execution-input +acquisition as `RecordReservationConflictError`; unavailable private fee records +produce a separate error. + +Use `Promise.allSettled` to keep track of every result, then claim each successful +swap. Record coordination is separate from blinded identity tracking: concurrent +calls MUST also share one blinded identity store instance, or provide distinct +explicit identities. The bundled blinded identity stores coordinate within one +process only. A shared SQLite record journal does not make blinded identity +allocation safe across processes. + ## Interrupted work and wallets Execution returns `complete` or `interrupted`, with known transaction ids and From 1e5a8294ea7aa740ad27a1ea257f45b07d7c5ac3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 07:18:16 -0400 Subject: [PATCH 17/32] [Test] Exercise concurrent inventory swaps on testnet --- .../fixtures/concurrentInventoryWorker.ts | 6 +- .../test/actions/swap/concurrentSwaps.test.ts | 2 +- .../concurrentInventory.e2e.test.ts | 182 ++++++++++++++++++ site/docs/guides/record-inventory.md | 26 +++ 4 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts diff --git a/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts b/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts index f76f79e5..b333bf31 100644 --- a/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts +++ b/packages/shield-swap-cli/test/fixtures/concurrentInventoryWorker.ts @@ -18,10 +18,10 @@ store.acquire = async 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 () => '', signMessage: async () => '' }, + 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, + 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}` @@ -35,5 +35,5 @@ try { process.send!({ nonce: result.outputs[0], attempts }) } finally { store.close() - process.disconnect() + process.disconnect!() } diff --git a/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts b/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts index 0767cd7b..766eea48 100644 --- a/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts +++ b/packages/shield-swap/test/actions/swap/concurrentSwaps.test.ts @@ -15,7 +15,7 @@ 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, recordPlaintext: plaintext(index + 1), + 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. */ 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/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index e58e56a6..8b124c88 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -357,3 +357,29 @@ units (default `0.1`). Each source record must remain above its configured minimum after that spend. These swaps pay the pool's trading fees; the return amount can differ from the forward amount. The tests use the confirmed claim amount for accounting and stop subsequent writes if a check fails. + +### Exercise concurrent swaps + +The concurrent suite starts with the same balanced inventories, then submits +six USDCx-to-ETH swaps together. It holds the first reservations until every +swap has selected its input, forcing contention. Each swap must reach proving +with a different record, and at least two executions must overlap. All +successful outputs are claimed before the suite reports any batch failures. + +The next batch returns the claimed ETH amounts through six concurrent swaps, +also using automatic record selection. The suite restores both balanced +inventories, checks exact debits and credits, and reopens the journal to verify +confirmed spends and the absence of duplicate inputs. + +```sh +VEIL_INVENTORY_E2E=1 \ +VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ +VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ +pnpm vitest run packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts --bail=1 +``` + +`VEIL_INVENTORY_RECORDS` also controls the batch size (default 6). +`VEIL_INVENTORY_SWAP_USDCX` controls each forward swap (default `0.1` USDCx), +so the default batch sells `0.6` USDCx in total. Run this suite separately from +the other funded suites so they do not compete for the account's records or +blinded identity counters. From e6c0495d3b4fe9fe253806a25884d10ef760bd0e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 07:34:58 -0400 Subject: [PATCH 18/32] [Docs] Expand record inventory action examples --- packages/core/src/actions/records/autoJoin.ts | 25 ++++++++++++++- .../src/actions/records/getRecordInventory.ts | 20 +++++++++++- .../core/src/actions/records/joinRecords.ts | 23 +++++++++++++- .../actions/records/planRecordInventory.ts | 26 +++++++++++++++- .../records/rebalanceRecordInventory.ts | 23 +++++++++++++- .../records/reconcileRecordInventory.ts | 25 +++++++++++++-- .../core/src/actions/records/splitRecord.ts | 18 ++++++++++- .../src/actions/inventory/planInventory.ts | 24 +++++++++++++- .../actions/inventory/rebalanceInventory.ts | 31 ++++++++++++++++++- 9 files changed, 205 insertions(+), 10 deletions(-) diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index 8460b4ce..65f1e073 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -24,7 +24,30 @@ export type AutoJoinParameters = Omit record.amount >= minAmount) + * } */ export async function autoJoin(client: Client, params: AutoJoinParameters): Promise { const inventory = await getRecordInventory(client, { asset: params.asset }) diff --git a/packages/core/src/actions/records/getRecordInventory.ts b/packages/core/src/actions/records/getRecordInventory.ts index f8a453bb..b4a290e4 100644 --- a/packages/core/src/actions/records/getRecordInventory.ts +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -27,7 +27,25 @@ export type GetRecordInventoryReturnType = { asset: ResolvedRecordAsset; availab * @returns Eligible records and their available balance; hits the network. * @throws On missing wallet grants, unsupported assets, or a non-terminating scan. * @example - * const inventory = await getRecordInventory(client, { asset: { program: 'credits.aleo' } }) + * import { getRecordInventory, type Client, type RecordAsset } from '@provablehq/veil-core' + * + * async function inspectCredits(client: Client) { + * // Amounts are integer microcredits. An enabled shared journal excludes + * // records held by concurrent operations from available and balance. + * const inventory = await getRecordInventory(client, { + * 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 diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index 696a07dd..1f430e45 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -24,7 +24,28 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryReco * @returns Submitted transaction id; acceptance is asynchronous. * @throws On incompatible records, overflow, contention or proving failure. * @example - * const transactionId = await joinRecords(client, { asset, records: [a, b] }) + * import { getRecordInventory, joinRecords, waitForConfirmation, type Client } from '@provablehq/veil-core' + * + * async function joinTwoCreditsRecords(client: Client) { + * // 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 getRecordInventory(client, { asset }) + * const records = inventory.available.slice(0, 2) + * if (records.length !== 2) throw new Error('Two available credits records are required') + * + * const transactionId = await joinRecords(client, { + * 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) diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts index 778719f3..3a1f6e4c 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -21,7 +21,31 @@ export type PlanRecordInventoryParameters = { asset: RecordAsset; target: Invent * @returns Plaintext-free dependency plan with intrinsic credits deductions. * @throws When the target is impossible or exceeds the transaction limit. * @example - * const plan = await planRecordInventory(client, { asset, target: { records: 4 } }) + * import { planRecordInventory, type Client } from '@provablehq/veil-core' + * + * async function previewSixCreditsRecords(client: Client) { + * const plan = await planRecordInventory(client, { + * 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 }) diff --git a/packages/core/src/actions/records/rebalanceRecordInventory.ts b/packages/core/src/actions/records/rebalanceRecordInventory.ts index 8924fb87..3ce0c772 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -34,7 +34,28 @@ export type RebalanceRecordInventoryParameters = { * @returns Completion or interruption with all submitted transaction ids; never rolls back accepted steps. * @throws On invalid/tampered plans or missing coordination, before submission. * @example - * const result = await rebalanceRecordInventory(client, { plan, maxFeeMicrocredits: 1_000_000n }) + * import { rebalanceRecordInventory, reconcileRecordInventory, type Client, type InventoryPlan } from '@provablehq/veil-core' + * + * async function executeReviewedPlan(client: Client, 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 rebalanceRecordInventory(client, { + * 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 reconcileRecordInventory(client) + * // 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) diff --git a/packages/core/src/actions/records/reconcileRecordInventory.ts b/packages/core/src/actions/records/reconcileRecordInventory.ts index be0d0d49..4d0cd242 100644 --- a/packages/core/src/actions/records/reconcileRecordInventory.ts +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -12,12 +12,33 @@ 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. - * @param client Client extended with recordActions and the original durable store. + * @param client Client with recordManagement.enabled and the original durable store. * @param params Optional exact-transaction rebroadcast, disabled by default. * @returns Current scoped journal. Reads the chain and optionally resubmits transactions. * @throws On storage/network errors other than transaction-not-found (HTTP 404). * @example - * const journal = await reconcileRecordInventory(client, { rebroadcast: true }) + * import { reconcileRecordInventory, type Client } from '@provablehq/veil-core' + * + * async function inspectRecovery(client: Client) { + * // Reopen the original store and enable recordManagement on the same account + * // and chain. The default only reads confirmations; it never rebroadcasts. + * const journal = await reconcileRecordInventory(client) + * 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, + * }))) + * return unresolved // Missing transactions remain reserved. + * } + * + * async function rebroadcastSavedTransactions(client: Client) { + * // Choose this explicitly after inspection. Only saved, proved transactions + * // are rebroadcast, with their original proof and id; nothing is rebuilt. + * await reconcileRecordInventory(client, { 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) diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts index 6b46c3e8..9489cade 100644 --- a/packages/core/src/actions/records/splitRecord.ts +++ b/packages/core/src/actions/records/splitRecord.ts @@ -21,7 +21,23 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor * @returns Submitted transaction id, before confirmation. * @throws When either output would be empty or inputs are incompatible. * @example - * const transactionId = await splitRecord(client, { asset, record, amount: 500_000n }) + * import { getRecordInventory, splitRecord, waitForConfirmation, type Client } from '@provablehq/veil-core' + * + * async function splitOneCredit(client: Client) { + * 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 getRecordInventory(client, { 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 splitRecord(client, { 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) diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts index c3b3568d..07444ab9 100644 --- a/packages/shield-swap/src/actions/inventory/planInventory.ts +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -19,7 +19,29 @@ export type PlanInventoryParameters = { token: string; target: InventoryTarget; * @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 - * const plan = await planInventory(client, { token: 'ALEO', target: { records: 4 }, api }) + * import type { Client } from '@provablehq/veil-core' + * import { planInventory, tokenData, parseUnits, type ApiClient } from '@provablehq/shield-swap-sdk' + * + * async function previewUsdcxInventory(client: Client, api: ApiClient) { + * const token = await tokenData(api, 'USDCx') + * const plan = await planInventory(client, { + * api, + * 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) + * // Keep this plan for review; execute it with core rebalanceRecordInventory. + * // A client extended with shieldSwapActions supplies api automatically: + * // await client.planInventory({ token: 'USDCx', target, maxTransactions: 24 }) + * return plan + * } */ export async function planInventory(client: Client, params: PlanInventoryParameters): Promise { const token = await tokenData(params.api, params.token) diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts index 9f2dc8be..8e2a4266 100644 --- a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -12,7 +12,36 @@ export type RebalanceInventoryParameters = PlanInventoryParameters & Omit { // Plan joins and splits for the DEX token's underlying asset. Core reserves From 2e324d5ab68e6416e10364c0d454e6fccc7e78c0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 07:35:10 -0400 Subject: [PATCH 19/32] [Docs] Add a concurrent swaps example with recovery --- examples/shield-swap/README.md | 113 ++++++++++ examples/shield-swap/concurrent-swaps.test.ts | 84 +++++++ examples/shield-swap/concurrent-swaps.ts | 211 ++++++++++++++++++ examples/shield-swap/examples.test.ts | 10 + packages/shield-swap/README.md | 5 + 5 files changed, 423 insertions(+) create mode 100644 examples/shield-swap/README.md create mode 100644 examples/shield-swap/concurrent-swaps.test.ts create mode 100644 examples/shield-swap/concurrent-swaps.ts diff --git a/examples/shield-swap/README.md b/examples/shield-swap/README.md new file mode 100644 index 00000000..1d589b8d --- /dev/null +++ b/examples/shield-swap/README.md @@ -0,0 +1,113 @@ +# 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` through a local secret environment +to 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 +explicitly sets `recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }`. +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 result for each confirmed claim includes output and any unspent input +refund; a partial fill is not reported as a full fill. + +## 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 `reconcileRecordInventory(client)` + 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..4fb064a9 --- /dev/null +++ b/examples/shield-swap/concurrent-swaps.test.ts @@ -0,0 +1,84 @@ +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<(params: { quote: SwapQuote }) => Promise>() + .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.map((result) => result.status)).toEqual(['claimed', 'swap-failed', 'claimed']) + expect(results[1]).toMatchObject({ error: failed }) + 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[0]).toMatchObject({ status: 'claim-failed', handle: handle(1), error: failure }) + expect(results[1]).toMatchObject({ status: 'claimed', claim: { 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..8a76f08f --- /dev/null +++ b/examples/shield-swap/concurrent-swaps.ts @@ -0,0 +1,211 @@ +/** + * 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 { rebalanceRecordInventory, reconcileRecordInventory } from '@provablehq/veil-core' +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' + +type Handle = Awaited> +type Claim = Awaited> +type BatchClient = Pick & { + swap: (params: { quote: SwapQuote }) => Promise +} + +/** + * Reports each swap independently, preserving handles when claiming is interrupted. + * @property status Separates failed submissions, unresolved claims and confirmed claims. + * @property quote Original terms for this trade, including output floor and decimals. + * @property handle Returned swap handle, present once submission succeeds. + * @property claim Confirmed claim and output/refund amounts, present on success. + * @property error Original failure for recovery, present when a swap or claim fails. + */ +export type ConcurrentSwapResult = + | { status: 'swap-failed'; quote: SwapQuote; error: unknown } + | { status: 'claim-failed'; quote: SwapQuote; handle: Handle; error: unknown } + | { status: 'claimed'; quote: SwapQuote; handle: Handle; claim: Claim } + +/** + * Submits a batch concurrently and attempts every successful swap's claim. + * Requires explicit record management with one shared journal and identity store. + * @param client Configured local signer; all calls share its reservation stores. + * @param offers Fresh quotes, one per intended trade. An empty list does nothing. + * @returns Ordered results, including unresolved handles; never retries submissions. + * @example + * const offer = await client.quote({ from: 'USDCx', to: 'ETH', amountIn: '0.1' }) + * const results = await submitConcurrentSwaps(client, Array.from({ length: 6 }, () => offer)) + */ +export async function submitConcurrentSwaps(client: BatchClient, offers: readonly SwapQuote[]): Promise { + // Leave tokenRecord unset. Each call selects from the inventory, atomically + // reserves an input and reselects if another call acquired that record first. + // allSettled keeps successful handles available when a sibling call fails. + const submissions = await Promise.allSettled(offers.map((quote) => client.swap({ quote }))) + return Promise.all(submissions.map(async (submission, index): Promise => { + const quote = offers[index]! + if (submission.status === 'rejected') return { status: 'swap-failed', quote, error: submission.reason } + const handle = submission.value + try { + // A swap and its claim are separate transactions. Observe the finalized + // output first; a timeout preserves this handle instead of starting over. + await client.waitForSwapOutput({ handle, timeout: 600_000, pollingInterval: 3_000 }) + const claim = await client.claimSwapOutput({ handle }) + return { status: 'claimed', quote, handle, claim } + } catch (error) { + return { status: 'claim-failed', quote, handle, error } + } + })) +} + +/** + * 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 +} + +/** + * Previews or executes inventory maintenance followed by a concurrent swap batch. + * Uses delegated proving with FeeMaster payment and account-scoped disk stores. + * @param options Testnet account, trade terms and explicit execution opt-in. + * @returns The inspected plan and ordered batch results; preview returns no 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') + const lock = await open(lockPath, 'wx', 0o600).catch((cause: unknown) => { + throw new Error(`Cannot acquire ${lockPath}; stop other runs before inspecting a stale lock`, { cause }) + }) + 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 }), + // Extending recordActions alone does not enable coordination. + 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 reconcileRecordInventory(client) + 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, results: [] as ConcurrentSwapResult[] } + } + + // 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') + const pending = await open(pendingPath, 'wx', 0o600).catch((cause: unknown) => { + throw new Error(`Resolve the previous batch before removing ${pendingPath}`, { cause }) + }) + await pending.close() + const maintenance = await rebalanceRecordInventory(client, { 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 results = await submitConcurrentSwaps(client, Array.from({ length: count }, () => offer)) + for (const [index, result] of results.entries()) { + if (result.status === 'claimed') { + console.log(`${index + 1}: ${result.handle.transactionId} -> ${result.claim.transactionId}; received ${formatUnits(result.claim.amountOut, offer.to.decimals)} ${offer.to.symbol}; refunded ${formatUnits(result.claim.amountRemaining, offer.from.decimals)} ${offer.from.symbol}`) + } else { + console.error(`${index + 1}: ${result.status}; ${'handle' in result ? result.handle.transactionId : 'inspect the reservation journal'}`) + } + } + // All claims have settled before reporting failures. Keep recovery files, + // including the pending marker, if any submission or claim was uncertain. + const failures = results.filter((result) => result.status !== 'claimed') + if (failures.length) throw new AggregateError(failures.map((result) => result.error), `${failures.length} trades need recovery in ${directory}; do not repeat the batch`) + await unlink(pendingPath) + return { plan, results } + } finally { + store?.close() + await lock.close() + await unlink(lockPath) + } +} + +// 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/packages/shield-swap/README.md b/packages/shield-swap/README.md index 1a33bbd3..adeb33af 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 From 32e09c9f37c3b49e0bbc2d63e3b784ce81ae2e03 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 07:58:32 -0400 Subject: [PATCH 20/32] [Refactor] Simplify concurrent swap examples --- examples/shield-swap/README.md | 15 ++- examples/shield-swap/concurrent-swaps.test.ts | 13 +- examples/shield-swap/concurrent-swaps.ts | 125 ++++++++---------- packages/core/src/actions/records/autoJoin.ts | 10 +- .../src/actions/records/getRecordInventory.ts | 6 +- .../core/src/actions/records/joinRecords.ts | 8 +- .../actions/records/planRecordInventory.ts | 6 +- .../records/rebalanceRecordInventory.ts | 8 +- .../records/reconcileRecordInventory.ts | 10 +- .../core/src/actions/records/splitRecord.ts | 8 +- .../src/actions/inventory/planInventory.ts | 16 +-- .../actions/inventory/rebalanceInventory.ts | 14 +- 12 files changed, 115 insertions(+), 124 deletions(-) diff --git a/examples/shield-swap/README.md b/examples/shield-swap/README.md index 1d589b8d..866f3132 100644 --- a/examples/shield-swap/README.md +++ b/examples/shield-swap/README.md @@ -13,8 +13,8 @@ Each trade spends 0.1 USDCx by default. Preparing the records can join and split 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` through a local secret environment -to an existing funded **testnet** account with DEX access. The example neither +(`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). @@ -27,7 +27,9 @@ pnpm exec tsx examples/shield-swap/concurrent-swaps.ts --execute ``` Execution uses delegated proving with FeeMaster paying network fees. It -explicitly sets `recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }`. +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: @@ -52,8 +54,9 @@ 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 result for each confirmed claim includes output and any unspent input -refund; a partial fill is not reported as a full fill. +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 @@ -78,7 +81,7 @@ 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 `reconcileRecordInventory(client)` + `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). diff --git a/examples/shield-swap/concurrent-swaps.test.ts b/examples/shield-swap/concurrent-swaps.test.ts index 4fb064a9..c1dc2993 100644 --- a/examples/shield-swap/concurrent-swaps.test.ts +++ b/examples/shield-swap/concurrent-swaps.test.ts @@ -20,7 +20,7 @@ const handle = (id: number) => ({ transactionId: `swap-${id}`, swapId: `${id}fie 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<(params: { quote: SwapQuote }) => Promise>() + const swap = vi.fn() .mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve))) .mockRejectedValueOnce(failed) .mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve))) @@ -36,8 +36,10 @@ it('starts every swap before awaiting results and claims successes despite a fai releases[1]!(handle(3)) const results = await pending - expect(results.map((result) => result.status)).toEqual(['claimed', 'swap-failed', 'claimed']) - expect(results[1]).toMatchObject({ error: failed }) + 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 }]]) }) @@ -57,8 +59,9 @@ describe('claim recovery', () => { }) const results = await submitConcurrentSwaps({ swap, waitForSwapOutput, claimSwapOutput }, [offer, offer]) - expect(results[0]).toMatchObject({ status: 'claim-failed', handle: handle(1), error: failure }) - expect(results[1]).toMatchObject({ status: 'claimed', claim: { amountOut: 80n, amountRemaining: 20n } }) + 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) diff --git a/examples/shield-swap/concurrent-swaps.ts b/examples/shield-swap/concurrent-swaps.ts index 8a76f08f..1fa464be 100644 --- a/examples/shield-swap/concurrent-swaps.ts +++ b/examples/shield-swap/concurrent-swaps.ts @@ -11,60 +11,41 @@ import { mkdir, open, unlink } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { rebalanceRecordInventory, reconcileRecordInventory } from '@provablehq/veil-core' 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' -type Handle = Awaited> -type Claim = Awaited> -type BatchClient = Pick & { - swap: (params: { quote: SwapQuote }) => Promise -} - /** - * Reports each swap independently, preserving handles when claiming is interrupted. - * @property status Separates failed submissions, unresolved claims and confirmed claims. - * @property quote Original terms for this trade, including output floor and decimals. - * @property handle Returned swap handle, present once submission succeeds. - * @property claim Confirmed claim and output/refund amounts, present on success. - * @property error Original failure for recovery, present when a swap or claim fails. - */ -export type ConcurrentSwapResult = - | { status: 'swap-failed'; quote: SwapQuote; error: unknown } - | { status: 'claim-failed'; quote: SwapQuote; handle: Handle; error: unknown } - | { status: 'claimed'; quote: SwapQuote; handle: Handle; claim: Claim } - -/** - * Submits a batch concurrently and attempts every successful swap's claim. - * Requires explicit record management with one shared journal and identity store. - * @param client Configured local signer; all calls share its reservation stores. - * @param offers Fresh quotes, one per intended trade. An empty list does nothing. - * @returns Ordered results, including unresolved handles; never retries submissions. + * 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 results = await submitConcurrentSwaps(client, Array.from({ length: 6 }, () => offer)) + * const batch = await submitConcurrentSwaps(client, Array(6).fill(offer)) + * console.log(batch.swaps.map((result) => result.status)) */ -export async function submitConcurrentSwaps(client: BatchClient, offers: readonly SwapQuote[]): Promise { - // Leave tokenRecord unset. Each call selects from the inventory, atomically - // reserves an input and reselects if another call acquired that record first. - // allSettled keeps successful handles available when a sibling call fails. - const submissions = await Promise.allSettled(offers.map((quote) => client.swap({ quote }))) - return Promise.all(submissions.map(async (submission, index): Promise => { - const quote = offers[index]! - if (submission.status === 'rejected') return { status: 'swap-failed', quote, error: submission.reason } - const handle = submission.value - try { - // A swap and its claim are separate transactions. Observe the finalized - // output first; a timeout preserves this handle instead of starting over. - await client.waitForSwapOutput({ handle, timeout: 600_000, pollingInterval: 3_000 }) - const claim = await client.claimSwapOutput({ handle }) - return { status: 'claimed', quote, handle, claim } - } catch (error) { - return { status: 'claim-failed', quote, handle, error } - } +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 } } /** @@ -88,10 +69,11 @@ export type ConcurrentSwapsOptions = { } /** - * Previews or executes inventory maintenance followed by a concurrent swap batch. - * Uses delegated proving with FeeMaster payment and account-scoped disk stores. + * 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 inspected plan and ordered batch results; preview returns no results. + * @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. @@ -113,9 +95,7 @@ export async function concurrentSwaps(options: ConcurrentSwapsOptions = {}) { // 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') - const lock = await open(lockPath, 'wx', 0o600).catch((cause: unknown) => { - throw new Error(`Cannot acquire ${lockPath}; stop other runs before inspecting a stale lock`, { cause }) - }) + await createMarker(lockPath, 'Stop other runs before inspecting a stale lock') let store: Awaited> | undefined try { store = await sqliteRecordInventoryStore(resolve(directory, 'inventory.sqlite')) @@ -124,7 +104,7 @@ export async function concurrentSwaps(options: ConcurrentSwapsOptions = {}) { networkUrl: 'https://edge.provable.com/api/v2', provingMode: 'delegated', useFeeMaster: true, confirmationTimeout: 600_000, records: aleo.createRemoteScanner({ waitForSync: true }), - // Extending recordActions alone does not enable coordination. + // All calls through this client share the same record reservations. recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }, }) const client = walletClient.extend(shieldSwapActions({ @@ -134,7 +114,7 @@ export async function concurrentSwaps(options: ConcurrentSwapsOptions = {}) { // Resolve known submissions before planning. Missing confirmations remain // reserved; they are not evidence that a previous transaction failed. - const journal = await reconcileRecordInventory(client) + 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() @@ -156,44 +136,51 @@ export async function concurrentSwaps(options: ConcurrentSwapsOptions = {}) { 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, results: [] as ConcurrentSwapResult[] } + 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') - const pending = await open(pendingPath, 'wx', 0o600).catch((cause: unknown) => { - throw new Error(`Resolve the previous batch before removing ${pendingPath}`, { cause }) - }) - await pending.close() - const maintenance = await rebalanceRecordInventory(client, { plan, timeoutMs: 600_000, pollIntervalMs: 3_000 }) + 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 results = await submitConcurrentSwaps(client, Array.from({ length: count }, () => offer)) - for (const [index, result] of results.entries()) { - if (result.status === 'claimed') { - console.log(`${index + 1}: ${result.handle.transactionId} -> ${result.claim.transactionId}; received ${formatUnits(result.claim.amountOut, offer.to.decimals)} ${offer.to.symbol}; refunded ${formatUnits(result.claim.amountRemaining, offer.from.decimals)} ${offer.from.symbol}`) + 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(`${index + 1}: ${result.status}; ${'handle' in result ? result.handle.transactionId : 'inspect the reservation journal'}`) + console.error(`Recover the claim for ${handle.transactionId} from ${directory}`) } } - // All claims have settled before reporting failures. Keep recovery files, - // including the pending marker, if any submission or claim was uncertain. - const failures = results.filter((result) => result.status !== 'claimed') - if (failures.length) throw new AggregateError(failures.map((result) => result.error), `${failures.length} trades need recovery in ${directory}; do not repeat the batch`) + // 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, results } + return { plan, ...batch } } finally { store?.close() - await lock.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) diff --git a/packages/core/src/actions/records/autoJoin.ts b/packages/core/src/actions/records/autoJoin.ts index 65f1e073..5b2a0a66 100644 --- a/packages/core/src/actions/records/autoJoin.ts +++ b/packages/core/src/actions/records/autoJoin.ts @@ -24,13 +24,13 @@ export type AutoJoinParameters = Omit record.amount >= minAmount) * } */ diff --git a/packages/core/src/actions/records/getRecordInventory.ts b/packages/core/src/actions/records/getRecordInventory.ts index b4a290e4..f1f26690 100644 --- a/packages/core/src/actions/records/getRecordInventory.ts +++ b/packages/core/src/actions/records/getRecordInventory.ts @@ -27,12 +27,12 @@ export type GetRecordInventoryReturnType = { asset: ResolvedRecordAsset; availab * @returns Eligible records and their available balance; hits the network. * @throws On missing wallet grants, unsupported assets, or a non-terminating scan. * @example - * import { getRecordInventory, type Client, type RecordAsset } from '@provablehq/veil-core' + * import type { WalletClient, RecordAsset } from '@provablehq/veil-core' * - * async function inspectCredits(client: Client) { + * 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 getRecordInventory(client, { + * const inventory = await client.getRecordInventory({ * asset: { program: 'credits.aleo' }, * }) * console.log('Available microcredits:', inventory.balance.toString()) diff --git a/packages/core/src/actions/records/joinRecords.ts b/packages/core/src/actions/records/joinRecords.ts index 1f430e45..afc1c9f1 100644 --- a/packages/core/src/actions/records/joinRecords.ts +++ b/packages/core/src/actions/records/joinRecords.ts @@ -24,17 +24,17 @@ export type JoinRecordsParameters = { asset: RecordAsset; records: InventoryReco * @returns Submitted transaction id; acceptance is asynchronous. * @throws On incompatible records, overflow, contention or proving failure. * @example - * import { getRecordInventory, joinRecords, waitForConfirmation, type Client } from '@provablehq/veil-core' + * import { waitForConfirmation, type WalletClient } from '@provablehq/veil-core' * - * async function joinTwoCreditsRecords(client: Client) { + * 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 getRecordInventory(client, { asset }) + * 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 joinRecords(client, { + * const transactionId = await client.joinRecords({ * asset, records, joinProgram: false, // Force the native two-record join. * }) * await waitForConfirmation(client, transactionId, 120_000) diff --git a/packages/core/src/actions/records/planRecordInventory.ts b/packages/core/src/actions/records/planRecordInventory.ts index 3a1f6e4c..54b2f10d 100644 --- a/packages/core/src/actions/records/planRecordInventory.ts +++ b/packages/core/src/actions/records/planRecordInventory.ts @@ -21,10 +21,10 @@ export type PlanRecordInventoryParameters = { asset: RecordAsset; target: Invent * @returns Plaintext-free dependency plan with intrinsic credits deductions. * @throws When the target is impossible or exceeds the transaction limit. * @example - * import { planRecordInventory, type Client } from '@provablehq/veil-core' + * import type { WalletClient } from '@provablehq/veil-core' * - * async function previewSixCreditsRecords(client: Client) { - * const plan = await planRecordInventory(client, { + * async function previewSixCreditsRecords(client: WalletClient) { + * const plan = await client.planRecordInventory({ * asset: { program: 'credits.aleo' }, * target: { * records: 6, diff --git a/packages/core/src/actions/records/rebalanceRecordInventory.ts b/packages/core/src/actions/records/rebalanceRecordInventory.ts index 3ce0c772..445d338a 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -34,12 +34,12 @@ export type RebalanceRecordInventoryParameters = { * @returns Completion or interruption with all submitted transaction ids; never rolls back accepted steps. * @throws On invalid/tampered plans or missing coordination, before submission. * @example - * import { rebalanceRecordInventory, reconcileRecordInventory, type Client, type InventoryPlan } from '@provablehq/veil-core' + * import type { WalletClient, InventoryPlan } from '@provablehq/veil-core' * - * async function executeReviewedPlan(client: Client, plan: InventoryPlan, signal?: AbortSignal) { + * 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 rebalanceRecordInventory(client, { + * const result = await client.rebalanceRecordInventory({ * plan, * timeoutMs: 120_000, // Per step: confirmation plus scanner visibility. * pollIntervalMs: 2_000, @@ -49,7 +49,7 @@ export type RebalanceRecordInventoryParameters = { * console.log('Confirmed steps:', result.completedSteps) * console.log('Submitted transactions:', result.transactionIds) * if (result.status === 'interrupted') { - * await reconcileRecordInventory(client) + * 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) diff --git a/packages/core/src/actions/records/reconcileRecordInventory.ts b/packages/core/src/actions/records/reconcileRecordInventory.ts index 4d0cd242..b78338b5 100644 --- a/packages/core/src/actions/records/reconcileRecordInventory.ts +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -17,12 +17,12 @@ export type ReconcileRecordInventoryParameters = { rebroadcast?: boolean } * @returns Current scoped journal. Reads the chain and optionally resubmits transactions. * @throws On storage/network errors other than transaction-not-found (HTTP 404). * @example - * import { reconcileRecordInventory, type Client } from '@provablehq/veil-core' + * import type { WalletClient } from '@provablehq/veil-core' * - * async function inspectRecovery(client: Client) { + * 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 reconcileRecordInventory(client) + * const journal = await client.reconcileRecordInventory() * const unresolved = journal.filter((entry) => * entry.status !== 'confirmed' && entry.status !== 'rejected' && entry.status !== 'cancelled', * ) @@ -32,10 +32,10 @@ export type ReconcileRecordInventoryParameters = { rebroadcast?: boolean } * return unresolved // Missing transactions remain reserved. * } * - * async function rebroadcastSavedTransactions(client: Client) { + * 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 reconcileRecordInventory(client, { rebroadcast: true }) + * await client.reconcileRecordInventory({ rebroadcast: true }) * // Broadcast acceptance is not confirmation. Inspect again on a later poll. * return inspectRecovery(client) * } diff --git a/packages/core/src/actions/records/splitRecord.ts b/packages/core/src/actions/records/splitRecord.ts index 9489cade..15d26dd6 100644 --- a/packages/core/src/actions/records/splitRecord.ts +++ b/packages/core/src/actions/records/splitRecord.ts @@ -21,19 +21,19 @@ export type SplitRecordParameters = { asset: RecordAsset; record: InventoryRecor * @returns Submitted transaction id, before confirmation. * @throws When either output would be empty or inputs are incompatible. * @example - * import { getRecordInventory, splitRecord, waitForConfirmation, type Client } from '@provablehq/veil-core' + * import { waitForConfirmation, type WalletClient } from '@provablehq/veil-core' * - * async function splitOneCredit(client: Client) { + * 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 getRecordInventory(client, { asset }) + * 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 splitRecord(client, { asset, record, amount }) + * 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 diff --git a/packages/shield-swap/src/actions/inventory/planInventory.ts b/packages/shield-swap/src/actions/inventory/planInventory.ts index 07444ab9..c48beef2 100644 --- a/packages/shield-swap/src/actions/inventory/planInventory.ts +++ b/packages/shield-swap/src/actions/inventory/planInventory.ts @@ -19,13 +19,13 @@ export type PlanInventoryParameters = { token: string; target: InventoryTarget; * @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 { Client } from '@provablehq/veil-core' - * import { planInventory, tokenData, parseUnits, type ApiClient } from '@provablehq/shield-swap-sdk' + * import type { WalletClient } from '@provablehq/veil-core' + * import { parseUnits, type ShieldSwapActions } from '@provablehq/shield-swap-sdk' * - * async function previewUsdcxInventory(client: Client, api: ApiClient) { - * const token = await tokenData(api, 'USDCx') - * const plan = await planInventory(client, { - * api, + * 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, @@ -37,9 +37,7 @@ export type PlanInventoryParameters = { token: string; target: InventoryTarget; * }) * // The plan names the spendable underlying program, not its AMM wrapper. * console.log('Asset:', plan.asset.program, 'Operations:', plan.steps.length) - * // Keep this plan for review; execute it with core rebalanceRecordInventory. - * // A client extended with shieldSwapActions supplies api automatically: - * // await client.planInventory({ token: 'USDCx', target, maxTransactions: 24 }) + * // After review, call client.rebalanceRecordInventory({ plan }). * return plan * } */ diff --git a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts index 8e2a4266..87a965cd 100644 --- a/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts +++ b/packages/shield-swap/src/actions/inventory/rebalanceInventory.ts @@ -12,16 +12,16 @@ export type RebalanceInventoryParameters = PlanInventoryParameters & Omit Date: Fri, 23 Oct 2026 07:58:54 -0400 Subject: [PATCH 21/32] [Docs] Explain record management and concurrent swaps --- README.md | 7 +- llms.txt | 2 + packages/core/README.md | 105 +++++--- packages/provable-sdk/README.md | 74 +++-- packages/shield-swap/README.md | 183 +++++++++---- site/docs/guides/record-inventory.md | 327 ++++++++++++++++++----- site/docs/guides/shield-swap.md | 160 +++++++++++ site/docs/guides/working-with-records.md | 4 + site/docs/packages/core.md | 14 + site/docs/packages/provable-sdk.md | 28 ++ site/docs/packages/shield-swap.md | 13 + 11 files changed, 732 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 399b285b..895e6e9b 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,11 @@ Shield Swap applies these operations to each DEX token's underlying program. maintenance and stores recovery data in SQLite. Record reservations and recovery are off by default. Follow the -[record inventory guide](site/docs/guides/record-inventory.md) to enable them, -configure storage, and coordinate maintenance with trading. +[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 diff --git a/llms.txt b/llms.txt index 8a5305a0..f8fe8a8b 100644 --- a/llms.txt +++ b/llms.txt @@ -31,6 +31,8 @@ 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. diff --git a/packages/core/README.md b/packages/core/README.md index ac36a9c8..e20aa6f8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -246,39 +246,72 @@ const client = createPublicClient({ })) ``` -## Record inventory - -Wallet clients expose `getRecordInventory`, `joinRecords`, `splitRecord`, -`autoJoin`, `planRecordInventory`, `rebalanceRecordInventory`, and reconciliation. -Reservations keep participating clients from spending the same record; -recovery tracks unfinished transactions. Both are off by default. Reads, plans, -and individual joins or splits remain available while they are disabled. - -To reserve records during writes and execute plans with dependent steps, pass -`recordManagement: { enabled: true, store?, chainId?, maxFeeMicrocredits?, tokenJoin? }` -to `createWalletClient` or `createClient`. An enabled client creates an in-memory -store if none is supplied. Disabled clients create no store. Core has no SQLite -or Node dependency. - -With a local signer, `writeContract` and `executeContract` accept `privateFee: true` -and an optional `feeRecord` plaintext. Managed writes replace a reserved or -unavailable fee record with a sufficient unreserved credits record, excluding -execution inputs. No eligible record means an error before proving. Wallet RPC -writes cannot use managed private fees until the wallet can pin the fee record. - -An asset needs only its program: `{ program: 'credits.aleo' }`. Optional -`recordName` and `amountField` select a custom record balance. Core reads the -program's join/split interface and defaults to `amount`, then `microcredits`; -native credits use `credits.microcredits`. - -When more than two records need joining, core prefers a compatible deployed -helper: up to 16 credits or dedicated stablecoin records, or 15 ARC records per -transaction. Set `tokenJoin: false` to use native pairwise joins, or supply -`{ program, maxRecords? }` to override the dynamic ARC router. Helper selection -also works with `enabled: false`; it does not enable reservations or recovery. - -`recordActions()` adds these methods to custom clients without enabling -reservations or recovery. Move `recordActions({ store, maxFeeMicrocredits })` -settings into the enabled client configuration to reserve records and enforce -fee limits. The [record inventory guide](../../site/docs/guides/record-inventory.md) -covers configuration, shared storage, and recovery after an interruption. +## 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. + +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/provable-sdk/README.md b/packages/provable-sdk/README.md index 4a7652b4..873752f8 100644 --- a/packages/provable-sdk/README.md +++ b/packages/provable-sdk/README.md @@ -161,31 +161,49 @@ 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. -## Inventory scanning and proving - -Wallet clients include inventory methods. Pass -`recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }` to -`createAleoClient` to reserve records during writes and track unfinished -transactions. `createDevnodeClient` accepts the same option; use a distinct -`chainId` for each local chain. Omission or `enabled: false` leaves ordinary -writes unchanged. - -Managed private fees select a credits record from the configured scanner. -`writeContract` and `executeContract` accept `privateFee: true` and optional -`feeRecord` plaintext. A reserved supplied record is replaced with an available -one that covers the estimated fee; no eligible record means an error. Local, -delegated, and devnode proving use the selected record. A pinned private fee -also overrides FeeMaster sponsorship for that call. - -Use `createRemoteScanner({ waitForSync: true })` to wait for the scanner to -catch up before planning inventory changes. `syncTimeoutMs` defaults to -300000 milliseconds; `waitForSync` defaults to false. Local and delegated -execution both await the `transaction-prepared` callback before submission, -so an application can save the proved transaction for recovery. The application -supplies storage; SQLite is available through -`@provablehq/shield-swap-cli/storage` (Node 22.13+). See -the [inventory guide](../../site/docs/guides/record-inventory.md). - -Dynamic ARC join helpers supply the underlying token as a dynamic import; -the proving adapter includes that program and its transitive static imports for -both local and delegated proofs. +## 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/shield-swap/README.md b/packages/shield-swap/README.md index adeb33af..9b0e3004 100644 --- a/packages/shield-swap/README.md +++ b/packages/shield-swap/README.md @@ -565,47 +565,126 @@ node never had from one it simply had not confirmed yet. ### Concurrent swaps -Concurrent swaps need separate input records and separate blinded identities. -Enable `recordManagement: { enabled: true, store, chainId }` when creating the -wallet client to coordinate records. If two automatic selections choose the same -record, `swap` and `swapMultiHop` retry with another eligible record before -proving. Candidates are tried from smallest to largest, once per nonce. Explicit -`tokenRecord` inputs remain fixed, and execution errors are not retried. +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. -Each swap also needs a single-use blinded identity. `shieldSwapActions` supplies -an in-memory identity store by default, shared by calls through that client. Use -a durable store to retain identities and claim handles after a restart: +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' -// walletClient was created with recordManagement.enabled: true. -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() +``` -// Requires at least two available USDCx records, each covering amountIn. -const results = await Promise.allSettled([ - client.swap({ poolKey: poolA, tokenInId: usdc, amountIn, imports }), - client.swap({ poolKey: poolA, tokenInId: usdc, amountIn, imports }), -]) -// Claim every fulfilled result, even if another swap failed. +Before each batch, reconcile unfinished transactions and prepare enough records: + +```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`. Each swap reserves its -identity before submission and saves its handle after confirmation; -`claimSwapOutput` marks it claimed. Concurrent clients in one process MUST share -the same identity store instance as well as the record store and chain identity. +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}`) +} -The bundled identity stores do not coordinate counters across processes. SQLite -can coordinate record reservations across processes, but a shared identity file -alone cannot prevent two processes from deriving the same blinded address. -Use distinct explicit identities when managing concurrent swaps across processes. +// 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() +``` -Record coordination remains off by default. Without it, callers MUST supply -disjoint input records or trade sequentially. See the -[record inventory guide](../../site/docs/guides/record-inventory.md#concurrent-swaps) -for retry boundaries and storage configuration. +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 @@ -1209,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 @@ -1354,24 +1454,3 @@ await client.collect({ The CLI and agent/MCP write handlers use the SDK default generator. Existing providers remain compatible, including explicit legacy empty-witness providers for deployments that accept them. Omission now checks the actual current list. - -## Record inventory - -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 ARC20/ARC22/credits actions. Participating clients MUST share the -same store and chain identity. The SDK has no database dependency. - -Follow the [record inventory guide](../../site/docs/guides/record-inventory.md) -for configuration, shared storage, and recovery after an interruption. diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 8b124c88..9b3e150f 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -1,70 +1,167 @@ -# Record inventory +--- +title: Record management +description: Configure private record inventory, shared reservations, fee selection, and recovery in Veil. +--- -A private token balance can span many records. A swap may need one record large -enough to spend, while concurrent trades benefit from several independent -records. Inventory actions join or split these records to prepare the balance -for spending. +# Record management -## Enable record coordination +Aleo stores private balances in records. Spending a record consumes it and can +produce new records, much like spending a Bitcoin UTXO. A swap needs one record +that covers its input amount; several concurrent swaps need several such +records. A sufficient total balance alone does not guarantee either layout. + +Veil's record methods inspect that inventory and prepare it with joins and +splits. Optional record management reserves inputs during execution and saves +transaction progress so participating clients can recover after an interruption. +This guide covers configuration, storage, maintenance, fees, and recovery. +For a trading example, see [concurrent Shield Swap swaps](./shield-swap.md#concurrent-swaps). -Wallet clients include record inventory methods. Reads, planning, and individual -joins or splits work without enabling reservations or recovery. +## Enable record coordination -Reservations keep participating clients from spending the same record. Recovery -tracks unfinished transactions so an interrupted operation can be reconciled -before more records are spent. Both are off by default, including after -`.extend(recordActions())`. Set `recordManagement.enabled: true` when creating -the client to enable them and execute plans with dependent steps. +Wallet clients already include the inventory methods. Reservations and recovery +are **off by default**, including after `.extend(recordActions())` or +`.extend(shieldSwapActions())`. Enable them when creating the client: ```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, // supplied by the application + privateKey, records: aleo.createRemoteScanner({ waitForSync: true }), - recordManagement: { - enabled: true, - chainId: 'aleo:testnet', - }, -}) -const asset = { program: 'credits.aleo' } -const plan = await client.planRecordInventory({ - asset, - target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n }, - maxTransactions: 10, -}) -// Review plan.steps and plan.deduction before explicitly executing. -const result = await client.rebalanceRecordInventory({ - plan, maxFeeMicrocredits: 1_000_000n, + 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) +``` + +This Node example keeps reservations in memory. Use a durable store for a +service that must recover after restarting. The key remains in the account; +record journals do not store private keys or record plaintext. + +`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 in-memory journal for each enabled client | Pass one shared store to every client using the same inventory. Use durable storage for restart 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 the client before attaching action decorators. Rebuild a managed +client when changing networks; in-flight work checks that its account and chain +remain unchanged. Use the same explicit `chainId` across participants: +`'testnet'` and `'aleo:testnet'` are different reservation namespaces even if +both clients contact the same testnet node. + +### Share a store and retain it across restarts + +The store holds its lock only while acquiring inputs. It acquires every input +for one transaction together or rejects the acquisition. Other calls can then reserve different records and +prove concurrently. The journal keeps acquired nonces excluded from selection. +A scanner read or an inventory plan alone does not reserve anything. + +| 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. ``` -Core defines the planning, execution, and storage interfaces without requiring -a specific runtime. The Aleo SDK supplies the scanner and local or delegated -proving. +Custom stores MUST atomically check and acquire all record nonces within the +chain/account scope. Implementing `list()` followed by an independent insert +allows two callers to acquire the same record. Persist each `acquire` and +`update` before resolving its promise. Keep confirmed inputs excluded while a +scanner still reports them as unspent, and retain a rejected transaction's +consumed private fee record. Do not release uncertain submissions on a timer. -An enabled client uses an in-memory store unless the caller supplies one. -Disabled clients create no store. To recover unfinished operations after a -restart, long-running services MUST supply a durable `RecordInventoryStore` -through `recordManagement.store`. SQLite storage is available only from the -`@provablehq/shield-swap-cli/storage` entry point (Node 22.13+). +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. -| Action | Behavior | +## Use the inventory methods + +| Method | Effect | | --- | --- | -| `getRecordInventory({ asset })` | Reads all scanner pages; separates available and reserved records. | -| `joinRecords({ asset, records })` | Joins two records natively, or up to 16 with an available helper (15 for dynamic ARC helpers); returns the transaction id. | -| `splitRecord({ asset, record, amount })` | Creates two outputs; `amount` is the first output's base-unit amount. | -| `autoJoin({ asset, minAmount? })` | Joins enough records to cover the amount, or all records if the amount is omitted. Execution requires record management to be enabled. | -| `planRecordInventory({ asset, target, maxTransactions? })` | Produces a read-only plan bound to its account and chain. | -| `rebalanceRecordInventory({ plan, ... })` | Runs each step and waits for its confirmed outputs to appear in the scanner. Requires record management to be enabled. | -| `reconcileRecordInventory({ rebroadcast? })` | Resolves pending journal entries and can rebroadcast the saved transaction. Requires record management to be enabled. | +| `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 + +Use `joinRecords` when the caller has already selected the exact inputs for one +transaction. Use `autoJoin` to obtain one record large enough for an upcoming +spend. This example requests one credit, expressed as 1,000,000 microcredits: + +```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') +} +``` + +The action preserves an existing sufficient record. Otherwise it selects the +largest records until their sum covers `minAmount`, prefers a compatible batch +helper, and waits for dependent outputs. Omitting `minAmount` consolidates all +eligible, unreserved records. It never chooses a different fee payment mode to +make the operation succeed. ## Select the record and amount field `RecordAsset` requires only `program`. Core reads the deployed program's join/split interface to select the record and its unsigned amount width. -ARC20/ARC22 tokens use `Token.amount`; native credits use +ARC-20/ARC-22 tokens use `Token.amount`; native credits use `credits.microcredits`. For other records, the default field is `amount`, then `microcredits`. `standard` is an optional hint checked against the program. @@ -82,7 +179,8 @@ 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. -A missing override is an error; core does not substitute another balance field. +An override naming an undeclared record or field is an error; core does not +substitute another balance field. `getRecordInventory` returns the resolved selection in `inventory.asset`. Compliance, registry, and recipient-bound records are excluded. @@ -101,6 +199,56 @@ splits. It is a bounded deterministic strategy, not an optimal transaction solver; it rejects layouts whose required intermediate joins overflow the token width. `maxTransactions` defaults to 100. +### 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() +} +``` + +`maxTransactions` limits the number of joins and splits in the plan (default +100, maximum 10,000). The action's `maxFeeMicrocredits` limits total network fees +for the run; the client setting limits each transaction. Both apply when set. +Intrinsic credits split deductions are separate from these fee limits. +`timeoutMs` covers confirmation and scanner visibility per step; proving time +is additional. An optional `signal` stops further work but does not cancel a +transaction already submitted. + ### Batch joins When more than two records need joining, core prefers available helper programs @@ -113,7 +261,7 @@ precedence over ARC defaults. | `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 ARC20/ARC22 tokens | `main_aj_arc20_2_15.aleo` | `test_aj_arc20_2_15.aleo` | 2 to 15 | +| 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 @@ -164,22 +312,33 @@ nested join's exact record to appear in the scanner. ## 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: {} })) -const plan = await dex.planInventory({ token: 'USDCx', target: { records: 4 } }) -const result = await dex.rebalanceInventory({ token: 'USDCx', target: { records: 4 } }) +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 }) ``` -Import `shieldSwapActions` from `@provablehq/shield-swap-sdk`. The wrapper reads -`underlyingProgram` from the token metadata and uses core's inventory actions. -Create the wallet with `recordManagement: { enabled: true, store, chainId }` -before applying `shieldSwapActions`. Token selection then skips reserved records. -`writeContract`/`executeTransaction`/`executeContract` reserve all explicit record -inputs together before proving. +`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. -Independent clients MUST share the same store and chain identity to avoid -spending each other's reserved inputs. Raw submissions and wallets outside this -configuration bypass the reservations. +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 @@ -203,15 +362,49 @@ explicit identities. The bundled blinded identity stores coordinate within one process only. A shared SQLite record journal does not make blinded identity allocation safe across processes. -## Interrupted work and wallets +Follow the [concurrent swaps walkthrough](./shield-swap.md#concurrent-swaps) +for client setup, six-record preparation, and separate swap/claim results. The +[runnable example](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) +adds a preview mode and restart guards. + +## Recover interrupted work + +Reopen the same journal with the same account and `chainId`, then inspect the +pending entries 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, +}))) +``` + +Reconciliation reads the chain and updates known outcomes. A 404 does not prove +rejection. If a prepared transaction is still missing and rebroadcast is the +chosen recovery action, explicitly pass `rebroadcast: true`: -Execution returns `complete` or `interrupted`, with known transaction ids and -completed steps. No accepted transaction is rolled back. Reconcile, scan, and -plan again after interruption; do not blindly replay an old plan. The executor -checks each input and only feeds confirmed output commitments into later steps. -Defaults are a 120-second confirmation/output-visibility deadline per step and -one-second polling; proving time is additional. Cancellation stops subsequent -work and retains unresolved reservations. +```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 does not repeat joins, create a new transaction, or automatically resume a +partially completed plan. Resolve uncertain submissions first, then scan and +plan against the remaining inventory. + +| 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 A local prover MUST honor the awaited `transaction-prepared` progress callback before broadcasting. Bundled SDK adapters do this for local and delegated @@ -229,7 +422,7 @@ Opaque wallet submission cannot enforce a fee ceiling before broadcast, so wallet execution rejects configured fee budgets. A lost wallet response without a transaction id retains its reservation for external reconciliation. -### Private fees +## Private fees Managed local-account writes support `privateFee: true`. If `feeRecord` is supplied, the client uses it when the current scan reports it as available and diff --git a/site/docs/guides/shield-swap.md b/site/docs/guides/shield-swap.md index c0a938d3..acdf03f2 100644 --- a/site/docs/guides/shield-swap.md +++ b/site/docs/guides/shield-swap.md @@ -247,6 +247,166 @@ blinding factor from the blinded address, so the dApp never holds it. See [`claimSwapOutput`](/api/shield-swap/claimSwapOutput) for the full recovery flow. +## Concurrent swaps + +Several private swaps can run at once when each has its own input record and +blinded identity. Record management coordinates the inputs; the identity store +retains the information needed to claim each output. Both are needed when a +process may stop before all claims finish. + +### 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. + +```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. + +```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 + +Each swap needs a distinct record large enough for its input amount. With +record management enabled and `tokenRecord` omitted, local `swap` and +`swapMultiHop` calls try available records from smallest to largest. If another +operation reserves the first choice, selection tries the next candidate before +proving. It scans once per call and tries each nonce at most once. Exhausting +that list fails; it does not wait for a new deposit or automatically join records. + +Selection preserves the quote's amount, output floor, blinded identity, and +nonce, and checks quote expiry and account/network consistency before another +attempt. Explicit `tokenRecord` inputs remain fixed. Proving, submission, and +confirmation errors never trigger another swap. Wallet accounts require pinned +token records; this automatic reselection flow applies to local signers. + +A quote reserves no pool liquidity. Concurrent trades can move the price against +each other, and each trade independently enforces its minimum output. A batch +is not atomic: some swaps can succeed while others fail. Check every result and +claim each successful output. A claim can return both output tokens and unspent +input; `amountRemaining` is the refund in input-token base units. + +For several clients in one process, share the same record store, `chainId`, and +blinded identity store object. SQLite coordinates record reservations across +processes, but the bundled blinded identity store does not coordinate counters +across processes. Run one process for the account with this setup. A shared +JSON path alone is insufficient; a multi-process application must separately +coordinate identity allocation or supply distinct explicit identities. + +A timeout does not prove failure. Keep `inventory.sqlite` and `blinded.json`, +reconcile pending transactions, and use `client.getUnclaimedSwaps()` to recover +known handles. Do not launch a replacement batch until uncertain submissions +are resolved. The runnable example also retains a pending-batch marker and +uses a lock file to stop overlapping runs against its directory. + +Run [concurrent-swaps.ts](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) +for the complete example with preview and execution modes. Its +[README](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/README.md) +covers commands and recovery. The CLI's `swap-concurrent` command retains a +one-input-token-per-leg restriction; use the SDK example for several simultaneous +swaps selling the same token. See [record management](./record-inventory.md) +for configuration defaults, fee limits, wallet grants, and join helper selection. + ## Liquidity Positions are concentrated-liquidity ranges, held as private records. 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.md b/site/docs/packages/shield-swap.md index 850c2a64..a9ae56a1 100644 --- a/site/docs/packages/shield-swap.md +++ b/site/docs/packages/shield-swap.md @@ -111,6 +111,19 @@ discovery, quoting, the `imports` map — and the wallet-signer variants. `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`. From e60681557007a0a3521c52cb0832bebe8e9950eb Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 08:02:16 -0400 Subject: [PATCH 22/32] [Docs] Clarify configurable record journal storage --- site/docs/guides/record-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 9b3e150f..365eadad 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -59,7 +59,7 @@ records and proving; see [wallet requirements](#interrupted-work-and-wallets). | 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 in-memory journal for each enabled client | Pass one shared store to every client using the same inventory. Use durable storage for restart recovery. | +| `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). | From 8de3c81a6a4f432cddd061344961c78f96e53e35 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 08:57:44 -0400 Subject: [PATCH 23/32] [Test] Validate every deployed inventory helper arity --- .../recordInventory.integration.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/core/test/integration/recordInventory.integration.test.ts b/packages/core/test/integration/recordInventory.integration.test.ts index c2e5f209..7291b376 100644 --- a/packages/core/test/integration/recordInventory.integration.test.ts +++ b/packages/core/test/integration/recordInventory.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createPublicClient, http, type RecordAsset } from '../../src/index.js' +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' @@ -32,14 +32,31 @@ describe.runIf(RUN)('live inventory program interfaces', () => { 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)) - // Check both ends of each deployment's range through the spending validator, - // including the 10/11 and 14/15 boundaries between static helper programs. + // Validate every supported arity through the spending validator. Interior + // functions must be compatible too, not only the deployment boundaries. for (const helper of helpers) { - await validateRecordJoinFunction(client, resolved.asset, helper, helper.counts[0]!) - await validateRecordJoinFunction(client, resolved.asset, helper, helper.counts.at(-1)!) + 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') From 4b0d1fd792d0006cf71d1d939d78ea956ba436c6 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 09:01:33 -0400 Subject: [PATCH 24/32] [Test] Exercise inventory execution guards on testnet --- .../recordInventoryGuards.e2e.test.ts | 175 ++++++++++++++++++ .../integration/recordInventoryHarness.ts | 8 +- site/docs/guides/record-inventory.md | 29 ++- 3 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts 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 index 9d27f19a..627776e0 100644 --- a/packages/shield-swap/test/integration/recordInventoryHarness.ts +++ b/packages/shield-swap/test/integration/recordInventoryHarness.ts @@ -4,7 +4,7 @@ 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, Transaction } 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' @@ -89,12 +89,12 @@ export async function createInventoryHarness() { 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) => aleo.createAleoClient({ + 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' } } : {}), + ...(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, })) @@ -163,7 +163,7 @@ export async function createInventoryHarness() { return { get client() { return client }, get store() { return store! }, identities, scope, tokens, count, directory, scan, confirm, outputRecords, assertBalanced, rebalance, - peer: () => connect(store), unmanaged: () => connect(), + peer: (options?: Pick) => connect(store, options), unmanaged: () => connect(), // Reopen the actual SQLite file to test recovery across client/store lifetimes. restart: async () => { store!.close() diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 365eadad..9fc8ac54 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -482,7 +482,9 @@ upgrading, set `tokenJoin: false`; omitting it now selects available helpers. ## Run the integration checks The deployed-interface check reads the testnet credits, USDCx, and ETH programs -and their join helpers. It needs no account and submits no transactions: +and their join helpers. It validates every supported record count, disabled +helper routing, and rejection of a static helper for another asset. It needs no +account and submits no transactions: ```sh VEIL_INTEGRATION=1 pnpm vitest run packages/core/test/integration/recordInventory.integration.test.ts @@ -522,6 +524,31 @@ operation remains unresolved. If a process was terminated, verify it has stopped before removing its `run.lock` file. +### Check execution guards and native joins + +The execution-guard suite uses the real scanner, delegated prover, and testnet +ledger. It checks modified plans, cancellation before submission, and inputs +reserved after planning. Both `writeContract` and `executeContract` must reject +a proved transaction above the configured fee ceiling and release its input. + +With helpers disabled, each token consolidates three records through two native +joins, then restores the original amounts through two splits. A separate case +cancels a dependent join sequence after its first submission, confirms that +transaction, and restores its inputs' amounts. The final check reopens SQLite +and verifies that every submitted transaction is confirmed and no work remains +unresolved. + +```sh +VEIL_INVENTORY_E2E=1 \ +VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ +pnpm vitest run packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts --bail=1 +``` + +After initial inventory preparation, this suite submits ten joins/splits and +builds two additional transactions that the fee ceiling prevents from being +broadcast. FeeMaster pays network fees; token balances remain unchanged. +Run it separately from other funded suites using the same account. + ### Exercise inventory during repeated swaps The strategy suite starts with six balanced records per token and makes three From d567094a44565eb37095d5fb2d7310a933909bd0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 09:21:50 -0400 Subject: [PATCH 25/32] [Docs] Walk through concurrent swap selection and recovery --- site/docs/guides/shield-swap.md | 210 +++++++++++++++++++++++++------- 1 file changed, 168 insertions(+), 42 deletions(-) diff --git a/site/docs/guides/shield-swap.md b/site/docs/guides/shield-swap.md index acdf03f2..ca54e368 100644 --- a/site/docs/guides/shield-swap.md +++ b/site/docs/guides/shield-swap.md @@ -249,10 +249,16 @@ flow. ## Concurrent swaps -Several private swaps can run at once when each has its own input record and -blinded identity. Record management coordinates the inputs; the identity store -retains the information needed to claim each output. Both are needed when a -process may stop before all claims finish. +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 @@ -262,6 +268,14 @@ 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' @@ -333,6 +347,9 @@ call time. If preparation is interrupted, stop before submitting any swaps. 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({ @@ -367,45 +384,154 @@ store.close() ### Selection, failures, and recovery -Each swap needs a distinct record large enough for its input amount. With -record management enabled and `tokenRecord` omitted, local `swap` and -`swapMultiHop` calls try available records from smallest to largest. If another -operation reserves the first choice, selection tries the next candidate before -proving. It scans once per call and tries each nonce at most once. Exhausting -that list fails; it does not wait for a new deposit or automatically join records. - -Selection preserves the quote's amount, output floor, blinded identity, and -nonce, and checks quote expiry and account/network consistency before another -attempt. Explicit `tokenRecord` inputs remain fixed. Proving, submission, and -confirmation errors never trigger another swap. Wallet accounts require pinned -token records; this automatic reselection flow applies to local signers. - -A quote reserves no pool liquidity. Concurrent trades can move the price against -each other, and each trade independently enforces its minimum output. A batch -is not atomic: some swaps can succeed while others fail. Check every result and -claim each successful output. A claim can return both output tokens and unspent -input; `amountRemaining` is the refund in input-token base units. - -For several clients in one process, share the same record store, `chainId`, and -blinded identity store object. SQLite coordinates record reservations across -processes, but the bundled blinded identity store does not coordinate counters -across processes. Run one process for the account with this setup. A shared -JSON path alone is insufficient; a multi-process application must separately -coordinate identity allocation or supply distinct explicit identities. - -A timeout does not prove failure. Keep `inventory.sqlite` and `blinded.json`, -reconcile pending transactions, and use `client.getUnclaimedSwaps()` to recover -known handles. Do not launch a replacement batch until uncertain submissions -are resolved. The runnable example also retains a pending-batch marker and -uses a lock file to stop overlapping runs against its directory. - -Run [concurrent-swaps.ts](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) -for the complete example with preview and execution modes. Its +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') +} + +// 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. +``` + +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 retains a -one-input-token-per-leg restriction; use the SDK example for several simultaneous -swaps selling the same token. See [record management](./record-inventory.md) -for configuration defaults, fee limits, wallet grants, and join helper selection. +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 From 7ee34c9d718353dd56e197e0886576a0a8de82a8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 09:21:58 -0400 Subject: [PATCH 26/32] [Docs] Explain record management with worked examples --- site/docs/guides/record-inventory.md | 701 ++++++++++++++++----------- 1 file changed, 414 insertions(+), 287 deletions(-) diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 9fc8ac54..3ea2d2f3 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -5,22 +5,46 @@ description: Configure private record inventory, shared reservations, fee select # Record management -Aleo stores private balances in records. Spending a record consumes it and can -produce new records, much like spending a Bitcoin UTXO. A swap needs one record -that covers its input amount; several concurrent swaps need several such -records. A sufficient total balance alone does not guarantee either layout. +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). -Veil's record methods inspect that inventory and prepare it with joins and -splits. Optional record management reserves inputs during execution and saves -transaction progress so participating clients can recover after an interruption. -This guide covers configuration, storage, maintenance, fees, and recovery. -For a trading example, see [concurrent Shield Swap swaps](./shield-swap.md#concurrent-swaps). +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())`. Enable them when creating the client: +`.extend(shieldSwapActions())`. Set `recordManagement.enabled` when creating the +client to enable them: ```ts import { memoryRecordInventoryStore } from '@provablehq/veil-core' @@ -44,9 +68,15 @@ console.log('Available records:', inventory.available.length) console.log('Reserved records:', inventory.reserved.length) ``` -This Node example keeps reservations in memory. Use a durable store for a -service that must recover after restarting. The key remains in the account; -record journals do not store private keys or record plaintext. +`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 @@ -69,18 +99,34 @@ 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 the client before attaching action decorators. Rebuild a managed -client when changing networks; in-flight work checks that its account and chain -remain unchanged. Use the same explicit `chainId` across participants: -`'testnet'` and `'aleo:testnet'` are different reservation namespaces even if -both clients contact the same testnet node. +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 -The store holds its lock only while acquiring inputs. It acquires every input -for one transaction together or rejects the acquisition. Other calls can then reserve different records and -prove concurrently. The journal keeps acquired nonces excluded from selection. -A scanner read or an inventory plan alone does not reserve anything. +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 | | --- | --- | --- | @@ -101,12 +147,31 @@ const store = await sqliteRecordInventoryStore('.veil/testnet/trading-account/in // Call store.close() only after all of those clients have finished their work. ``` -Custom stores MUST atomically check and acquire all record nonces within the -chain/account scope. Implementing `list()` followed by an independent insert -allows two callers to acquire the same record. Persist each `acquire` and -`update` before resolving its promise. Keep confirmed inputs excluded while a -scanner still reports them as unspent, and retain a rejected transaction's -consumed private fee record. Do not release uncertain submissions on a timer. +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 @@ -115,6 +180,10 @@ claim handles; its [concurrent setup](./shield-swap.md#concurrent-swaps) covers ## 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. | @@ -132,9 +201,13 @@ requires it when joins are needed; an existing sufficient record returns ### Join only enough for a spend -Use `joinRecords` when the caller has already selected the exact inputs for one -transaction. Use `autoJoin` to obtain one record large enough for an upcoming -spend. This example requests one credit, expressed as 1,000,000 microcredits: +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({ @@ -151,21 +224,83 @@ if (result.status === 'interrupted') { } ``` -The action preserves an existing sufficient record. Otherwise it selects the -largest records until their sum covers `minAmount`, prefers a compatible batch -helper, and waits for dependent outputs. Omitting `minAmount` consolidates all -eligible, unreserved records. It never chooses a different fee payment mode to -make the operation succeed. +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 -`RecordAsset` requires only `program`. Core reads the deployed program's -join/split interface to select the record and its unsigned amount width. -ARC-20/ARC-22 tokens use `Token.amount`; native credits use -`credits.microcredits`. For other records, the default field is `amount`, -then `microcredits`. `standard` is an optional hint checked against the program. +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: -Supply `recordName` or `amountField` to override either default: +| 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 = { @@ -181,23 +316,36 @@ 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. -`getRecordInventory` returns the resolved selection in `inventory.asset`. -Compliance, registry, and recipient-bound records are excluded. -Amounts use `bigint` in base units. Credits are bounded by u64 and ARC tokens by -u128; custom fields use their declared width (u8, u16, u32, u64, or u128). -A native credits split deducts 10,000 microcredits from its outputs, separately -from any transaction fee. Plans report the total intrinsic deduction. +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 -`target.records` is 1–1000. `minRecordAmount` defaults to `1n`; -`distribution` defaults to `preserve`. `balanced` aims for equal amounts with -`toleranceBps` defaulting to 1000 (10%). The planner preserves already suitable -records and direct balanced splits where possible, otherwise consolidates and -splits. It is a bounded deterministic strategy, not an optimal transaction -solver; it rejects layouts whose required intermediate joins overflow the token -width. `maxTransactions` defaults to 100. +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 @@ -241,20 +389,38 @@ if (result.status === 'interrupted') { } ``` -`maxTransactions` limits the number of joins and splits in the plan (default -100, maximum 10,000). The action's `maxFeeMicrocredits` limits total network fees -for the run; the client setting limits each transaction. Both apply when set. -Intrinsic credits split deductions are separate from these fee limits. -`timeoutMs` covers confirmation and scanner visibility per step; proving time -is additional. An optional `signal` stops further work but does not cancel a -transaction already submitted. +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 -When more than two records need joining, core prefers available helper programs -that combine them in one transaction. The exported `recordJoinPrograms` map -lists candidates by network and source asset. Exact program entries take -precedence over ARC defaults. +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 | | --- | --- | --- | --- | @@ -274,20 +440,28 @@ 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 picks the largest compatible batch that preserves the target count -and fits the amount width. Sixteen credits records use one `join_16` call; -seventeen use `join_16`, then a native join with its confirmed output. -Two-record joins use the native program by default. `maxTransactions` counts -submitted transactions, including batch joins and native splits. Plans retain -the selected helpers so execution follows the reviewed plan. +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. | -`recordManagement.tokenJoin` controls overrides: +For example, to force native joins on testnet: -- Omit it to select helpers by asset and network. -- Set `false` to use native pairwise joins for every asset. -- Set `{ program, maxRecords? }` to select a dynamic ARC router explicitly. - `maxRecords` defaults to 15 and accepts 2 to 15. This router also handles - two-record token joins; credits still use their automatically selected helpers. +```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 @@ -297,18 +471,22 @@ 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. -Credits and dedicated stablecoin helpers take only records. Dynamic ARC helpers -prepend the underlying program identifier, such as `'test_arc20_eth'`, and -supply the token as a dynamic import. The Aleo SDK resolves its transitive -imports for local and delegated proving. Wallet adapters must support the -selected helper's inputs and pinned records from the underlying program. +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. -With management enabled, a batch reserves all inputs together. The journal -attributes the call to the source asset and retains fees and proved transactions -for recovery. Helper execution reads the record commitment from the final nested -join, whose output contains the encrypted record. The outer helper's external -or dynamic return id is a separate commitment. Dependent steps wait for the -nested join's exact record to appear in the scanner. +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 @@ -342,35 +520,48 @@ must use the same store and chain identity to see each other's reservations. ### Concurrent swaps -With coordination enabled, local `swap` and `swapMultiHop` calls can sell the -same token concurrently when enough separate records cover the trades. Automatic -selection scans once and tries eligible records from smallest to largest. If -another operation reserves a candidate first, the swap tries the next one. Each -record nonce is tried at most once; exhaustion fails before proving. - -Retries preserve the amount, slippage floor, blinded identity and nonce. The -client checks the account, network and quote expiry before each attempt. -Explicit `tokenRecord` inputs stay fixed. Proving, submission and confirmation -errors are never retried by record selection. Core reports failed execution-input -acquisition as `RecordReservationConflictError`; unavailable private fee records -produce a separate error. - -Use `Promise.allSettled` to keep track of every result, then claim each successful -swap. Record coordination is separate from blinded identity tracking: concurrent -calls MUST also share one blinded identity store instance, or provide distinct -explicit identities. The bundled blinded identity stores coordinate within one -process only. A shared SQLite record journal does not make blinded identity -allocation safe across processes. - -Follow the [concurrent swaps walkthrough](./shield-swap.md#concurrent-swaps) -for client setup, six-record preparation, and separate swap/claim results. The -[runnable example](https://github.com/ProvableHQ/veil/blob/main/examples/shield-swap/concurrent-swaps.ts) -adds a preview mode and restart guards. +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 -Reopen the same journal with the same account and `chainId`, then inspect the -pending entries before submitting anything new: +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() @@ -382,9 +573,14 @@ console.table(unresolved.map((entry) => ({ }))) ``` -Reconciliation reads the chain and updates known outcomes. A 404 does not prove -rejection. If a prepared transaction is still missing and rebroadcast is the -chosen recovery action, explicitly pass `rebroadcast: true`: +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 }) @@ -392,9 +588,18 @@ await client.reconcileRecordInventory({ rebroadcast: true }) ``` Rebroadcast sends the exact saved transaction with its existing proof and id. -It does not repeat joins, create a new transaction, or automatically resume a -partially completed plan. Resolve uncertain submissions first, then scan and -plan against the remaining inventory. +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. + +### 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 | | --- | --- | @@ -406,56 +611,86 @@ plan against the remaining inventory. ## Interrupted work and wallets -A local prover MUST honor the awaited `transaction-prepared` progress callback -before broadcasting. Bundled SDK adapters do this for local and delegated -proving; delegated proving requests `broadcast: false` when checkpoints are -needed. The callback persists the transaction and checks the configured fee -ceiling. Custom `ProvingConfig.execute` adapters must follow this same contract. - -Wallet clients need granted plaintext or record fields (`$nonce`, the selected amount field, -record name, and `recipient_bound` when applicable), plus a pinned record uid -for spending without plaintext. Dependent execution also needs output commitment -metadata (`commitment` or a `$commitment` grant). Rebalance checks consumed -records for this metadata before submitting its first step; missing metadata -returns an interrupted result with no submitted transactions. -Opaque wallet submission cannot enforce a fee ceiling before broadcast, so -wallet execution rejects configured fee budgets. A lost wallet response without -a transaction id retains its reservation for external reconciliation. +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. ## Private fees -Managed local-account writes support `privateFee: true`. If `feeRecord` is -supplied, the client uses it when the current scan reports it as available and -it covers the estimated base fee plus priority fee. Otherwise the client selects -the smallest sufficient credits record. Selection excludes existing reservations -and the transaction's own inputs. If none is available, the call fails before -proving; it does not automatically join records or change the fee payment mode. +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 -await client.writeContract({ +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, - // Optional: feeRecord: preferredCreditsRecord.recordPlaintext, + // Omit feeRecord to select an available credits record automatically. }) +console.log('Submitted transfer:', transferId) ``` -The fee record and execution inputs are reserved together. Local, delegated, -and devnode adapters receive the selected plaintext through `feeRecord`; the -journal stores only its nonce, alongside the encrypted proved transaction. -Before broadcast, the manager checks the fee transition's record tag and fee -amounts. Explicit private payment takes precedence over FeeMaster sponsorship. +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. -On a rejected execution, Aleo can still charge the private fee. The journal -releases the execution inputs but keeps `feeRecordId` excluded from stale scans. -Custom stores MUST preserve that exclusion for rejected entries. A timeout or -missing confirmation retains every reservation until reconciliation resolves it. +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. -Custom local provers need `estimateExecutionFee`, must honor `feeRecord`, and -must await the prepared-transaction callback before broadcasting. Wallet RPC -writes still require public fees or FeeMaster: the current wallet protocol -cannot pin a selected fee record. +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/cli.md). @@ -468,138 +703,30 @@ and recovery enabled. `createClient`, `createWalletClient`, `aleo.createAleoClient`, and `createDevnodeClient` accept this option. Wallet clients include the inventory methods without a `recordActions` extension. -`recordActions({ store, maxFeeMicrocredits })` accepts these options but ignores -them: they do not reserve records, enforce fee limits, or enable recovery. -Its `chainId` and `tokenJoin` options apply only when the client was created -without record management settings. Settings supplied during client creation -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. - - -## Run the integration checks +For example, configure a fee ceiling and native joins on the client itself: -The deployed-interface check reads the testnet credits, USDCx, and ETH programs -and their join helpers. It validates every supported record count, disabled -helper routing, and rejection of a static helper for another asset. It needs no -account and submits no transactions: - -```sh -VEIL_INTEGRATION=1 pnpm vitest run packages/core/test/integration/recordInventory.integration.test.ts -``` - -The inventory lifecycle suite uses a funded testnet account to join and split -USDCx and ETH records. It checks asset defaults and overrides, shared -reservations, reviewed plans, and recovery of a prepared transaction after -reopening its SQLite journal. Both tokens finish with six balanced records. -Run it from the repository root: - -```sh -VEIL_INVENTORY_E2E=1 \ -VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ -VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ -pnpm vitest run packages/shield-swap/test/integration/recordInventory.e2e.test.ts --bail=1 -``` - -`VEIL_E2E_STATE_FILE` accepts an existing Shield Swap testnet session containing -`network: "testnet"` and `privateKey`. `VEIL_E2E_PRIVATE_KEY` can supply the key -instead. The suites use delegated proving and FeeMaster. They do not create or -fund an account. - -`VEIL_INVENTORY_RECORDS` sets the count per token (default 6, range 3 to 16). -`VEIL_INVENTORY_MIN_USDCX` defaults to `0.1` USDCx and -`VEIL_INVENTORY_MIN_ETH` to `0.000001` ETH. The account must hold at least the -minimum multiplied by `(record count + 1)` for each token. Amount settings use -human-readable token units. The tests rebalance the account's full available -inventory for both tokens; use a dedicated test account. - -The journal defaults to `.veil/inventory-e2e/testnet/
/inventory.sqlite`. -Set `VEIL_INVENTORY_E2E_DIR` to move that directory. `VEIL_BLINDED_STORE` selects -the swap recovery file; its default is `blinded.json` in the journal directory. -Run suites sequentially for a given account and retain both stores after a -failure. The next run reconciles the journal and refuses to proceed while an -operation remains unresolved. If a process was terminated, verify it has stopped -before removing its `run.lock` file. - - -### Check execution guards and native joins - -The execution-guard suite uses the real scanner, delegated prover, and testnet -ledger. It checks modified plans, cancellation before submission, and inputs -reserved after planning. Both `writeContract` and `executeContract` must reject -a proved transaction above the configured fee ceiling and release its input. - -With helpers disabled, each token consolidates three records through two native -joins, then restores the original amounts through two splits. A separate case -cancels a dependent join sequence after its first submission, confirms that -transaction, and restores its inputs' amounts. The final check reopens SQLite -and verifies that every submitted transaction is confirmed and no work remains -unresolved. - -```sh -VEIL_INVENTORY_E2E=1 \ -VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ -pnpm vitest run packages/shield-swap/test/integration/recordInventoryGuards.e2e.test.ts --bail=1 +```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. ``` -After initial inventory preparation, this suite submits ten joins/splits and -builds two additional transactions that the fee ceiling prevents from being -broadcast. FeeMaster pays network fees; token balances remain unchanged. -Run it separately from other funded suites using the same account. - -### Exercise inventory during repeated swaps - -The strategy suite starts with six balanced records per token and makes three -USDCx-to-ETH-to-USDCx round trips through a live Shield Swap pool. Each forward -swap spends `0.1` USDCx by default. The return swap spends the entire claimed ETH -record, preserving the six existing ETH records. Every leg is claimed before -the next swap begins. - -After each round, the suite restores the USDCx target with a batch join followed -by splits. It checks record counts, minimum amounts, exact balance changes, and -confirmed reservations. The final check reopens the journal and verifies that -no input record was consumed by two confirmed transactions. - -Run this after the lifecycle suite, using the same account and stores: +For an application still passing settings to the decorator: -```sh -VEIL_INVENTORY_E2E=1 \ -VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ -VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ -pnpm vitest run packages/shield-swap/test/integration/recordInventoryStrategy.e2e.test.ts --bail=1 -``` - -`VEIL_INVENTORY_ROUNDS` sets the number of round trips (default 3, range 1 to 5). -`VEIL_INVENTORY_SWAP_USDCX` sets the amount spent on each forward swap in USDCx -units (default `0.1`). Each source record must remain above its configured -minimum after that spend. These swaps pay the pool's trading fees; the return -amount can differ from the forward amount. The tests use the confirmed claim -amount for accounting and stop subsequent writes if a check fails. - -### Exercise concurrent swaps - -The concurrent suite starts with the same balanced inventories, then submits -six USDCx-to-ETH swaps together. It holds the first reservations until every -swap has selected its input, forcing contention. Each swap must reach proving -with a different record, and at least two executions must overlap. All -successful outputs are claimed before the suite reports any batch failures. - -The next batch returns the claimed ETH amounts through six concurrent swaps, -also using automatic record selection. The suite restores both balanced -inventories, checks exact debits and credits, and reopens the journal to verify -confirmed spends and the absence of duplicate inputs. - -```sh -VEIL_INVENTORY_E2E=1 \ -VEIL_E2E_STATE_FILE=/absolute/path/to/testnet/state.json \ -VEIL_BLINDED_STORE=/absolute/path/to/testnet/blinded.json \ -pnpm vitest run packages/shield-swap/test/integration/concurrentInventory.e2e.test.ts --bail=1 -``` +| 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. | -`VEIL_INVENTORY_RECORDS` also controls the batch size (default 6). -`VEIL_INVENTORY_SWAP_USDCX` controls each forward swap (default `0.1` USDCx), -so the default batch sells `0.6` USDCx in total. Run this suite separately from -the other funded suites so they do not compete for the account's records or -blinded identity counters. +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. From 27dfc489be6043aedc67259e012fc243a22f9693 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 10:20:08 -0400 Subject: [PATCH 27/32] [Docs] Explain Shield Swap API quotes and finalization --- site/docs/guides/shield-swap.md | 363 ++++++++++++++++++++---------- site/docs/packages/shield-swap.md | 2 +- 2 files changed, 247 insertions(+), 118 deletions(-) diff --git a/site/docs/guides/shield-swap.md b/site/docs/guides/shield-swap.md index ca54e368..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,296 @@ 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. | -[`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. +`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. + +### 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. -## Claim the output +## Finalize a swap -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. +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. + +### Wait for the result, then claim it + +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, +}) -console.log(`received ${claimed!.amountOut}, refunded ${claimed!.amountRemaining}`) +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)) ``` -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. +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 @@ -547,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/packages/shield-swap.md b/site/docs/packages/shield-swap.md index a9ae56a1..4328e50a 100644 --- a/site/docs/packages/shield-swap.md +++ b/site/docs/packages/shield-swap.md @@ -106,7 +106,7 @@ 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. From 1d24487c2871c976874cc2f94dced5afd2abfa27 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 10:20:17 -0400 Subject: [PATCH 28/32] [Docs] Add concurrent trading guidance to LLM index --- llms.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/llms.txt b/llms.txt index f8fe8a8b..31e4ae1c 100644 --- a/llms.txt +++ b/llms.txt @@ -36,6 +36,22 @@ Core and Shield Swap expose `/agent` tool schemas and `/mcp` tools. Before opera - [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. From 896496b89350a3583b939c7e127685f20b1ab928 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 11:17:32 -0400 Subject: [PATCH 29/32] [Fix] Preserve feasible inventory split targets --- packages/core/src/inventory/planner.ts | 17 ++++++++-- .../core/test/inventory/inventory.test.ts | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/core/src/inventory/planner.ts b/packages/core/src/inventory/planner.ts index d9c357a3..577f1368 100644 --- a/packages/core/src/inventory/planner.ts +++ b/packages/core/src/inventory/planner.ts @@ -116,10 +116,21 @@ export function buildInventoryPlan(params: { 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 outputsNeeded = repack ? target.records - working.length + 1 : 2 const deduction = splitDeduction(asset) - const usable = record.amount - deduction * BigInt(outputsNeeded - 1) - const amount = usable / BigInt(outputsNeeded) + 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') diff --git a/packages/core/test/inventory/inventory.test.ts b/packages/core/test/inventory/inventory.test.ts index 8efafbc3..ba4e6e2c 100644 --- a/packages/core/test/inventory/inventory.test.ts +++ b/packages/core/test/inventory/inventory.test.ts @@ -101,6 +101,39 @@ describe('inventory planning', () => { 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) From d18b88c81b76fc868d0b0f35be24ba00dc68f346 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 11:18:14 -0400 Subject: [PATCH 30/32] [Docs] Fold inventory into the Shield Swap CLI page --- site/docs/guides/record-inventory.md | 2 +- site/docs/packages/cli.md | 25 -------------- site/docs/packages/shield-swap-cli.md | 48 +++++++++++++++++++++++++-- site/sidebars.ts | 1 - 4 files changed, 46 insertions(+), 30 deletions(-) delete mode 100644 site/docs/packages/cli.md diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index 3ea2d2f3..f8099c4b 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -693,7 +693,7 @@ 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/cli.md). +restart journal, use the [Shield Swap inventory commands](../packages/shield-swap-cli.md#record-inventory). ## Migrate a recordActions configuration diff --git a/site/docs/packages/cli.md b/site/docs/packages/cli.md deleted file mode 100644 index 91da7570..00000000 --- a/site/docs/packages/cli.md +++ /dev/null @@ -1,25 +0,0 @@ -# Shield Swap inventory - -`shield-swap inventory` manages spendable records for ARC20, ARC22, native -credits, and compatible custom assets. It ships in `@provablehq/shield-swap-cli` -and requires Node 22.13+ for its SQLite journal. SDK packages retain their -existing runtime support. - -```sh -shield-swap inventory plan --asset credits.aleo --records 4 -shield-swap inventory run --config inventory.json --execute -``` - -The commands are `inspect`, `plan`, `rebalance`, `run`, and `status`. Inventory -uses the saved Shield Swap account or a key supplied through the environment; -it does not require DEX authentication. Writes require `--execute`. Without it, -`rebalance` and `run` print plans. - -Run the foreground loop under a service manager for background operation. -`--json` returns one object; a continuous `run --execute --json` emits its -summary on shutdown. Set `VEIL_INVENTORY_DB` on participating trading commands -to share reservations with maintenance. Trading coordination remains off unless -that variable is set. - -See the [configuration and recovery reference](https://github.com/ProvableHQ/veil/blob/main/packages/shield-swap-cli/INVENTORY.md), -[Shield Swap CLI](./shield-swap-cli.md), and [inventory SDK guide](../guides/record-inventory.md). diff --git a/site/docs/packages/shield-swap-cli.md b/site/docs/packages/shield-swap-cli.md index 86d8b70b..45a9083b 100644 --- a/site/docs/packages/shield-swap-cli.md +++ b/site/docs/packages/shield-swap-cli.md @@ -28,6 +28,48 @@ use the [Shield Swap SDK](./shield-swap) instead. ## Record inventory -Use `shield-swap inventory` to inspect, plan, rebalance, and maintain spendable -records. It uses the configured account and requires Node 22.13+. See the -[inventory commands](./cli.md) for configuration, shared reservations, and recovery. +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/sidebars.ts b/site/sidebars.ts index 46527f47..853fff4c 100644 --- a/site/sidebars.ts +++ b/site/sidebars.ts @@ -40,7 +40,6 @@ const sidebars: SidebarsConfig = { items: [ 'packages/overview', 'packages/core', - 'packages/cli', 'packages/provable-sdk', 'packages/wallet-adapter', 'packages/react', From 096ac88ea8223aa85be6b8999735298d4b407b16 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 12:09:14 -0400 Subject: [PATCH 31/32] [Fix] Reject wallet inventory fee ceilings before scanning --- .../records/rebalanceRecordInventory.ts | 9 +++++- .../core/test/inventory/configuration.test.ts | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/core/src/actions/records/rebalanceRecordInventory.ts b/packages/core/src/actions/records/rebalanceRecordInventory.ts index 445d338a..a8626885 100644 --- a/packages/core/src/actions/records/rebalanceRecordInventory.ts +++ b/packages/core/src/actions/records/rebalanceRecordInventory.ts @@ -20,6 +20,7 @@ import { inventoryOutputCommitments } from '../../inventory/tokenJoin.js' * @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 @@ -32,7 +33,7 @@ export type RebalanceRecordInventoryParameters = { * @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 or missing coordination, before submission. + * @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' * @@ -70,6 +71,12 @@ export async function rebalanceRecordInventory(client: Client, params: Rebalance 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. diff --git a/packages/core/test/inventory/configuration.test.ts b/packages/core/test/inventory/configuration.test.ts index 6a2b3432..7450dde8 100644 --- a/packages/core/test/inventory/configuration.test.ts +++ b/packages/core/test/inventory/configuration.test.ts @@ -45,6 +45,38 @@ describe('explicit record management configuration', () => { 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 From eb070689dd37225ccbd766262e66880882134978 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 23 Oct 2026 12:17:26 -0400 Subject: [PATCH 32/32] [Fix] Retain rebroadcast diagnostics and reconcile later spends --- .changeset/record-inventory.md | 2 + apps/loyalty-dapp/src/lib/aleo.ts | 3 + examples/e2e-demo.ts | 30 +++++ packages/core/README.md | 5 + .../records/reconcileRecordInventory.ts | 41 +++++-- packages/core/src/inventory/store.ts | 8 +- packages/core/src/inventory/types.ts | 9 +- packages/core/test/inventory/recovery.test.ts | 110 ++++++++++++++++++ packages/shield-swap-cli/INVENTORY.md | 13 +++ .../shield-swap-cli/src/inventory/runner.ts | 10 +- .../test/inventory-command.test.ts | 6 +- .../test/inventory-runner.test.ts | 12 ++ .../test/inventory-storage.test.ts | 23 ++++ site/docs/guides/record-inventory.md | 26 ++++- 14 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 packages/core/test/inventory/recovery.test.ts diff --git a/.changeset/record-inventory.md b/.changeset/record-inventory.md index e5693730..aa027cf1 100644 --- a/.changeset/record-inventory.md +++ b/.changeset/record-inventory.md @@ -14,3 +14,5 @@ Prefer available batch join helpers for more than two records: up to 16 credits 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/apps/loyalty-dapp/src/lib/aleo.ts b/apps/loyalty-dapp/src/lib/aleo.ts index 954d7ae1..33b275b6 100644 --- a/apps/loyalty-dapp/src/lib/aleo.ts +++ b/apps/loyalty-dapp/src/lib/aleo.ts @@ -70,6 +70,9 @@ export async function getLoyaltyContract(walletClient?: ReturnType { // 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() diff --git a/packages/core/README.md b/packages/core/README.md index e20aa6f8..1219944f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -289,6 +289,11 @@ use `client.joinRecords` or `client.splitRecord`; these return before confirmati `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 diff --git a/packages/core/src/actions/records/reconcileRecordInventory.ts b/packages/core/src/actions/records/reconcileRecordInventory.ts index b78338b5..03e11cea 100644 --- a/packages/core/src/actions/records/reconcileRecordInventory.ts +++ b/packages/core/src/actions/records/reconcileRecordInventory.ts @@ -12,10 +12,12 @@ 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. Reads the chain and optionally resubmits transactions. - * @throws On storage/network errors other than transaction-not-found (HTTP 404). + * @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' * @@ -28,6 +30,7 @@ export type ReconcileRecordInventoryParameters = { rebroadcast?: boolean } * ) * 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. * } @@ -48,25 +51,41 @@ export async function reconcileRecordInventory(client: Client, params: Reconcile 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. - const result = await getConfirmedTransaction(client, { id: entry.transactionId }) - if (result.status === 'accepted' || result.status === 'rejected') { - await config.store.update(scope, entry.id, { status: result.status === 'accepted' ? 'confirmed' : 'rejected', transaction: undefined }) - continue - } - throw new Error('Unexpected transaction confirmation response') + 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 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') - await config.store.update(scope, entry.id, { status: 'submitted' }) + 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/inventory/store.ts b/packages/core/src/inventory/store.ts index 7c1b9c7b..a09f5f01 100644 --- a/packages/core/src/inventory/store.ts +++ b/packages/core/src/inventory/store.ts @@ -27,9 +27,11 @@ export function memoryRecordInventoryStore(): RecordInventoryStore { 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 clear a completed proof instead - // of retaining the old transaction when the patch requests its removal. - if ('transaction' in patch && patch.transaction === undefined) delete entry.transaction + // 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] + } }, } } diff --git a/packages/core/src/inventory/types.ts b/packages/core/src/inventory/types.ts index 6b178e0e..40307401 100644 --- a/packages/core/src/inventory/types.ts +++ b/packages/core/src/inventory/types.ts @@ -135,6 +135,8 @@ export type RecordSpendStatus = 'reserved' | 'prepared' | 'submitted' | 'confirm * @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 @@ -150,6 +152,8 @@ export type RecordSpend = { transactionId?: string transaction?: Transaction feeMicrocredits?: string + rebroadcastAttempts?: number + lastRebroadcastError?: string } /** @@ -159,7 +163,8 @@ export type RecordSpend = { * 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 for an existing operation. + * @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() */ @@ -167,7 +172,7 @@ export type RecordInventoryStore = { list(scope: string): Promise acquire(entry: Omit): Promise update(scope: string, id: string, patch: Partial>): Promise + 'status' | 'transactionId' | 'transaction' | 'feeMicrocredits' | 'rebroadcastAttempts' | 'lastRebroadcastError'>>): Promise } /** 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/shield-swap-cli/INVENTORY.md b/packages/shield-swap-cli/INVENTORY.md index 2cb6b54f..219c8690 100644 --- a/packages/shield-swap-cli/INVENTORY.md +++ b/packages/shield-swap-cli/INVENTORY.md @@ -131,6 +131,19 @@ 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: diff --git a/packages/shield-swap-cli/src/inventory/runner.ts b/packages/shield-swap-cli/src/inventory/runner.ts index daa08958..40e4bdf4 100644 --- a/packages/shield-swap-cli/src/inventory/runner.ts +++ b/packages/shield-swap-cli/src/inventory/runner.ts @@ -30,7 +30,15 @@ export async function runInventoryCycle(client: Client, config: InventoryConfig, // 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) return [{ program: '*', status: 'waiting', reason: 'Unresolved transactions retain their reservations' }] + 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 diff --git a/packages/shield-swap-cli/test/inventory-command.test.ts b/packages/shield-swap-cli/test/inventory-command.test.ts index f17de3fd..d56be102 100644 --- a/packages/shield-swap-cli/test/inventory-command.test.ts +++ b/packages/shield-swap-cli/test/inventory-command.test.ts @@ -115,10 +115,12 @@ describe('shield-swap inventory command', () => { 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', transaction: { id: 'at1saved', proof: 'stored-transaction-body' } as any }) + 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' }) + 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) }) diff --git a/packages/shield-swap-cli/test/inventory-runner.test.ts b/packages/shield-swap-cli/test/inventory-runner.test.ts index 90006a38..843211e8 100644 --- a/packages/shield-swap-cli/test/inventory-runner.test.ts +++ b/packages/shield-swap-cli/test/inventory-runner.test.ts @@ -48,6 +48,18 @@ describe('inventory application policies', () => { 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') diff --git a/packages/shield-swap-cli/test/inventory-storage.test.ts b/packages/shield-swap-cli/test/inventory-storage.test.ts index 6843f2cd..3dd2d60e 100644 --- a/packages/shield-swap-cli/test/inventory-storage.test.ts +++ b/packages/shield-swap-cli/test/inventory-storage.test.ts @@ -92,6 +92,29 @@ describe('CLI-only durable inventory store', () => { 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' })})`) diff --git a/site/docs/guides/record-inventory.md b/site/docs/guides/record-inventory.md index f8099c4b..00b65c15 100644 --- a/site/docs/guides/record-inventory.md +++ b/site/docs/guides/record-inventory.md @@ -570,6 +570,7 @@ const unresolved = journal.filter((entry) => ) console.table(unresolved.map((entry) => ({ operation: entry.id, transactionId: entry.transactionId, status: entry.status, + attempts: entry.rebroadcastAttempts ?? 0, error: entry.lastRebroadcastError, }))) ``` @@ -592,6 +593,27 @@ 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 @@ -638,7 +660,9 @@ 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. +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