Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
71ed97b
[Feat] Add record inventory actions and background CLI
iamalwaysuncomfortable Oct 1, 2026
4d2da4c
[Feat] Batch token inventory joins through configurable autojoin routers
Roee-87 Oct 7, 2026
89be787
Validate RPC commitment grants before rebalancing record inventory
Roee-87 Oct 9, 2026
cddd34c
fixed readme
Roee-87 Oct 9, 2026
1c85f1c
[Docs] Explain record inventory execution and recovery
iamalwaysuncomfortable Oct 22, 2026
571f2e7
[Fix] Require explicit record management configuration
iamalwaysuncomfortable Oct 22, 2026
7bdfade
[Docs] Clarify record inventory configuration and recovery
iamalwaysuncomfortable Oct 22, 2026
f527032
[Feat] Infer record assets and select batch join helpers
iamalwaysuncomfortable Oct 22, 2026
36805eb
[Test] Verify deployed record inventory helpers
iamalwaysuncomfortable Oct 22, 2026
c1b1aa9
[Fix] Resolve static join helper record outputs
iamalwaysuncomfortable Oct 22, 2026
e28412b
[Fix] Resolve dynamic join outputs from nested transitions
iamalwaysuncomfortable Oct 22, 2026
bc43b10
[Test] Exercise live record inventory lifecycle and recovery
iamalwaysuncomfortable Oct 22, 2026
c7ed6e6
[Test] Verify balanced inventories through repeated swaps
iamalwaysuncomfortable Oct 22, 2026
d032080
[Refactor] Move record inventory into the Shield Swap CLI
iamalwaysuncomfortable Oct 22, 2026
6172445
[Feat] Coordinate private fee record selection
iamalwaysuncomfortable Oct 22, 2026
2f168a6
[Fix] Reselect records for concurrent swaps
iamalwaysuncomfortable Oct 23, 2026
1e5a829
[Test] Exercise concurrent inventory swaps on testnet
iamalwaysuncomfortable Oct 23, 2026
e6c0495
[Docs] Expand record inventory action examples
iamalwaysuncomfortable Oct 23, 2026
2e324d5
[Docs] Add a concurrent swaps example with recovery
iamalwaysuncomfortable Oct 23, 2026
32e09c9
[Refactor] Simplify concurrent swap examples
iamalwaysuncomfortable Oct 23, 2026
3da8d30
[Docs] Explain record management and concurrent swaps
iamalwaysuncomfortable Oct 23, 2026
e606815
[Docs] Clarify configurable record journal storage
iamalwaysuncomfortable Oct 23, 2026
8de3c81
[Test] Validate every deployed inventory helper arity
iamalwaysuncomfortable Oct 23, 2026
4b0d1fd
[Test] Exercise inventory execution guards on testnet
iamalwaysuncomfortable Oct 23, 2026
d567094
[Docs] Walk through concurrent swap selection and recovery
iamalwaysuncomfortable Oct 23, 2026
7ee34c9
[Docs] Explain record management with worked examples
iamalwaysuncomfortable Oct 23, 2026
27dfc48
[Docs] Explain Shield Swap API quotes and finalization
iamalwaysuncomfortable Oct 23, 2026
1d24487
[Docs] Add concurrent trading guidance to LLM index
iamalwaysuncomfortable Oct 23, 2026
896496b
[Fix] Preserve feasible inventory split targets
iamalwaysuncomfortable Oct 23, 2026
d18b88c
[Docs] Fold inventory into the Shield Swap CLI page
iamalwaysuncomfortable Oct 23, 2026
096ac88
[Fix] Reject wallet inventory fee ceilings before scanning
iamalwaysuncomfortable Oct 23, 2026
eb07068
[Fix] Retain rebroadcast diagnostics and reconcile later spends
iamalwaysuncomfortable Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/record-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@provablehq/veil-core": minor
"@provablehq/veil-aleo-sdk": minor
"@provablehq/shield-swap-sdk": minor
"@provablehq/shield-swap-cli": minor
---

Add scanner-backed ARC20, ARC22, and native credits inventory actions, shared record reservations, and Shield Swap inventory wrappers. Add shield-swap inventory commands with bounded maintenance policies and a CLI-only SQLite recovery journal exported from @provablehq/shield-swap-cli/storage. SDK packages retain their existing runtime requirements.

Allow inventory assets to specify only a program, with optional record-name and amount-field overrides. Resolve amount before microcredits for custom records and use the deployed unsigned integer width.

Prefer available batch join helpers for more than two records: up to 16 credits or dedicated stablecoin records, and 15 dynamic ARC records. Validate helper interfaces, capture them in plans, and match their confirmed outputs before dependent steps. Explicit dynamic routers remain supported; tokenJoin: false selects native pairwise joins. Reservations and recovery remain off unless recordManagement.enabled is true.

Support private fees on managed local-account writes. Prefer an eligible supplied fee record, otherwise reserve a sufficient unreserved credits record alongside the execution inputs. Pass that record through local, delegated, and devnode proving, verify it before broadcast, and retain charged private fee records after rejected executions.

Retry automatic local Shield Swap record selection on pre-proving reservation conflicts. Keep swap terms fixed, bound retries to one scan, and preserve explicit inputs and execution errors. Export RecordReservationConflictError for input acquisition failures.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ const tools = aleoAgentTools({
// Each tool has { schema, handler } — plug into LangChain, Vercel AI SDK, etc.
```

## Record inventory management

Core reads records from a scanner and joins or splits them to prepare an
ARC20, ARC22, native credits, or compatible custom balance for spending. An asset
needs only its program; optional `recordName` and `amountField` select a custom
balance. Joins of more than two records prefer available batch helper programs.
Shield Swap applies these operations to each DEX token's underlying program.
[`shield-swap inventory`](packages/shield-swap-cli/INVENTORY.md) runs background
maintenance and stores recovery data in SQLite.

Record reservations and recovery are off by default. Follow the
[record management guide](site/docs/guides/record-inventory.md) to enable them,
configure storage, select private fee records, and recover interrupted work.
The [concurrent swaps walkthrough](site/docs/guides/shield-swap.md#concurrent-swaps)
shows how to prepare records, share both stores, and claim successful swaps
when another trade fails.

## License

MIT
21 changes: 21 additions & 0 deletions apps/loyalty-dapp/src/lib/aleo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export function createAleoWalletClient(adapter: AleoWalletAdapter) {
account,
// Wallet transport handles executions; http handles reads as fallback
transport: fallback([walletTransport, http(API_URL, { network: 'mainnet' })]),
// Configure record planning without enabling reservations on wallet writes.
// Managed private fees need a pinned fee record, which the current wallet
// protocol cannot request. Local SDK signers support that selection.
recordManagement: { enabled: false, chainId: 'aleo:mainnet' },
})
}

Expand All @@ -60,3 +64,20 @@ export async function getLoyaltyContract(walletClient?: ReturnType<typeof create
: publicClient,
})
}

/**
* Returns the connected wallet with its built-in inventory methods.
* Planning requires wallet grants for record identities and amounts; coordination stays disabled.
* Enabling coordination makes competing explicit inputs throw RecordReservationConflictError;
* wallet input requests remain fixed and are never automatically replaced.
* @param walletClient Connected wallet client on Aleo mainnet.
* @returns The same wallet client; the helper performs no scan or transaction.
* @example
* const inventory = createInventoryClient(createAleoWalletClient(adapter))
* const plan = await inventory.planRecordInventory({
* asset: { program: 'credits.aleo' }, target: { records: 4 },
* })
*/
export function createInventoryClient(walletClient: ReturnType<typeof createAleoWalletClient>) {
return walletClient
}
36 changes: 36 additions & 0 deletions examples/e2e-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { beforeAll, describe, it, expect } from 'vitest'
import {
createPublicClient,
createWalletClient,
http,
parseProgram,
getContract,
Expand Down Expand Up @@ -235,3 +236,38 @@ describe('E2E: veil against live Aleo mainnet', () => {
console.log(' aleo_describe_program: found', descResult.functions.length, 'functions,', descResult.mappings.length, 'mappings')
})
}, { timeout: 30_000 })

// Inventory planning consumes the account's provider and performs no writes.
// A live application supplies createRemoteScanner({ waitForSync: true }) instead.
// With management enabled, privateFee: true also reserves a separate credits
// record. An optional feeRecord is preferred only while it remains available;
// the client replaces reserved candidates before building the transaction.
// Managed swaps can reselect automatically before proving. Direct writes and
// explicit tokenRecord inputs instead surface RecordReservationConflictError;
// proving/submission failures must be reconciled before any new spend.
describe('Record inventory consumer example', () => {
it('plans four credits records while keeping the source record private', async () => {
const aleo = await loadNetwork('testnet')
const account = aleo.generateAccount()
const client = createWalletClient({
account,
transport: http(API_URL, { network: 'testnet' }),
proving: aleo.createProvingConfig({ mode: 'delegated', networkUrl: API_URL, account }),
// Routing and planning remain available without enabling reservations.
recordManagement: { enabled: false, chainId: 'aleo:testnet' },
recordProvider: { requestRecords: async () => [{
programName: 'credits.aleo', recordName: 'credits', tag: '1field', spent: false,
recordPlaintext: `{ owner: ${account.address}.private, microcredits: 4030000u64.private, _nonce: 1group.public }`,
}] },
})
const plan = await client.planRecordInventory({
asset: { program: 'credits.aleo' },
target: { records: 4, distribution: 'balanced', minRecordAmount: 1_000_000n },
})
expect(client.recordManagement?.enabled).toBe(false)
expect(client.recordManagement?.store).toBeUndefined()
expect(plan.steps).toHaveLength(3)
expect(plan.deduction).toBe(30_000n)
expect(plan.inputs).toEqual([{ id: '1group', amount: 4_030_000n }])
})
})
116 changes: 116 additions & 0 deletions examples/shield-swap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Shield Swap examples

These examples call the Shield Swap SDK from TypeScript. Start with
[setup-client.ts](./setup-client.ts) for account setup and
[swap.ts](./swap.ts) for a single swap and claim.

## Run concurrent swaps

[concurrent-swaps.ts](./concurrent-swaps.ts) prepares six balanced USDCx
records, submits six USDCx → ETH swaps concurrently, and claims their outputs.
Each trade spends 0.1 USDCx by default. Preparing the records can join and split
**the account's entire available USDCx inventory**; it leaves reserved records
out of the plan. At least 0.6 USDCx must be available.

Run from the repository root with Node 22.13 or newer and dependencies installed
(`pnpm install`). Set `VEIL_E2E_PRIVATE_KEY` in the local environment to the private key of an
existing funded testnet account with DEX access. The example neither
creates nor funds an account. For setup, follow the
[Shield Swap runbook](../../packages/shield-swap/skills/SKILL.md).

```sh
# Reads inventory, lists maintenance operations, and quotes a trade. No submissions.
pnpm exec tsx examples/shield-swap/concurrent-swaps.ts

# Prepares the records, submits six swaps, and claims each successful output.
pnpm exec tsx examples/shield-swap/concurrent-swaps.ts --execute
```

Execution uses delegated proving with FeeMaster paying network fees. It
sets `recordManagement: { enabled: true, store, chainId: 'aleo:testnet' }`
on the wallet client. The client already includes record methods;
`shieldSwapActions()` adds the trading methods.
Record management remains disabled for clients that omit that configuration.

To change the trade terms, import the example from another local script:

```ts
import { concurrentSwaps } from './concurrent-swaps.js'

// amountIn is a decimal string in input-token units. Preview is the default.
await concurrentSwaps({ from: 'USDCx', to: 'ETH', amountIn: '0.2', count: 6 })

// Run separately after inspecting the preview; this spends 1.2 USDCx in total.
await concurrentSwaps({ from: 'USDCx', to: 'ETH', amountIn: '0.2', count: 6, execute: true })
```

The plan requires one sufficient record per trade. Each `client.swap({ quote })`
selects its own input and acquires a reservation atomically. If another swap
reserves that input first, the SDK selects another candidate before proving.
The example leaves `tokenRecord` unset so this selection path applies.
Quotes retain their minimum output, but do not reserve pool liquidity; concurrent
trades can still fail as pool prices change.

`submitConcurrentSwaps` uses `Promise.allSettled` so one failed submission does
not discard successful handles. It then waits for every successful swap's
output and attempts its claim. A failed claim does not prevent other claims.
The helper returns `swaps`, `handles`, and `claims`. Each `claims[i]` belongs to
`handles[i]`, including failed claims that need recovery. Successful claims
report both the output and any unspent input refund.

## Keep recovery data

The default directory is `.veil/concurrent-swaps/testnet/<account-address>/`.
Pass `directory` to choose another location, and reuse it for that account:

| File | Purpose |
| --- | --- |
| `inventory.sqlite` | Stores record reservations and prepared transactions for reconciliation. |
| `blinded.json` | Stores sensitive blinded identities and handles needed to claim swap outputs. |
| `run.lock` | Prevents overlapping runs of this example against the same directory. |
| `batch.pending` | Remains after an interrupted batch so a later execution cannot silently repeat it. |

Keep these files private and outside version control. SQLite coordinates record
reservations across processes, but the identity store coordinates calls only
within one process. **Do not run another trader for the same account while this
example is running**, even with a different directory. Multiple clients within
one process must share the same inventory store and identity store objects.

After an interruption:

1. Stop the previous process. Remove a stale `run.lock` only after confirming
that process has exited.
2. Recreate the client with the same account, network, `inventory.sqlite`, and
`blinded.json` paths shown in the example. Call `client.reconcileRecordInventory()`
to inspect pending transactions. A missing confirmation or a timeout does not
prove failure; keep its reservation. The record guide describes
[exact-transaction rebroadcast](../../site/docs/guides/record-inventory.md).
3. Call `client.syncBlindedIdentities()` and `client.getUnclaimedSwaps()` to
locate finalized outputs. Claim entries carrying a handle with
`client.claimSwapOutput({ handle })`. Investigate unresolved submissions and
missing handles through the journal and swap history before any new trade.
4. Remove `batch.pending` only after every submitted transaction and claim has
been resolved. Preview the current inventory again before starting a new batch.

The example never automatically rebuilds or repeats a swap after an uncertain
submission. All successful sibling swaps finish their claim attempts before an
error is reported, and both persistent stores survive that error.

## Check the example

```sh
# Offline checks of concurrent scheduling, claim failures, and command guards.
pnpm exec vitest run examples/shield-swap/concurrent-swaps.test.ts
pnpm exec tsc -p examples/shield-swap/tsconfig.json

# Live, read-only preview with a funded account already in the environment.
VEIL_INTEGRATION=1 pnpm exec vitest run examples/shield-swap/examples.test.ts -t 'concurrent-swaps preview'

# Explicitly opt into the funded six-swap batch and its inventory maintenance.
VEIL_INTEGRATION=1 VEIL_EXAMPLES_SPEND=1 VEIL_CONCURRENT_EXAMPLE=1 \
pnpm exec vitest run examples/shield-swap/examples.test.ts -t 'prepares six records'
```

The offline tests replace network actions to check the example's orchestration.
The opt-in test exercises real scanning, planning, joins/splits when needed,
record reservations, delegated proving, submission, confirmation, and claims.
87 changes: 87 additions & 0 deletions examples/shield-swap/concurrent-swaps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import type { ShieldSwapActions, SwapQuote } from '@provablehq/shield-swap-sdk'
import { submitConcurrentSwaps } from './concurrent-swaps.js'

const offer: SwapQuote = {
version: 1, network: 'testnet', program: 'shield_swap.aleo',
from: { id: '1field', symbol: 'USDCx', decimals: 6 },
to: { id: '2field', symbol: 'ETH', decimals: 18 },
amountIn: 100_000n, expectedOut: 100n, minOut: 99n, slippageBps: 100,
hops: [{ poolKey: '3field', tokenInId: '1field', tokenOutId: '2field' }],
quotedAt: 1, expiresAt: 60_001, protocolRevision: 1,
}
type Handle = Awaited<ReturnType<ShieldSwapActions['swap']>>
// Network-bound actions are substituted here; these tests exercise the example's
// scheduling and recovery logic. Record contention is covered by the SDK suites.
const handle = (id: number) => ({ transactionId: `swap-${id}`, swapId: `${id}field`, program: 'shield_swap.aleo' }) as Handle

it('starts every swap before awaiting results and claims successes despite a failed submission', async () => {
const releases: Array<(value: Handle) => void> = []
const failed = new Error('submission outcome uncertain')
const swap = vi.fn()
.mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve)))
.mockRejectedValueOnce(failed)
.mockImplementationOnce(() => new Promise((resolve) => releases.push(resolve)))
const waitForSwapOutput = vi.fn().mockResolvedValue({})
const claimSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => ({
transactionId: `claim-${item.transactionId}`, amountOut: 100n, amountRemaining: 0n,
}))

const pending = submitConcurrentSwaps({ swap, waitForSwapOutput, claimSwapOutput }, [offer, offer, offer])
expect(swap).toHaveBeenCalledTimes(3)
expect(waitForSwapOutput).not.toHaveBeenCalled()
releases[0]!(handle(1))
releases[1]!(handle(3))
const results = await pending

expect(results.swaps.map((result) => result.status)).toEqual(['fulfilled', 'rejected', 'fulfilled'])
expect(results.swaps[1]).toMatchObject({ reason: failed })
expect(results.handles).toEqual([handle(1), handle(3)])
expect(results.claims.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled'])
expect(claimSwapOutput).toHaveBeenCalledTimes(2)
expect(swap.mock.calls).toEqual([[{ quote: offer }], [{ quote: offer }], [{ quote: offer }]])
})

describe('claim recovery', () => {
it.each(['wait', 'claim'])('retains a handle after %s failure without resubmitting or abandoning other claims', async (phase) => {
let id = 0
const swap = vi.fn().mockImplementation(async () => handle(++id))
const failure = new Error('confirmation timed out')
const waitForSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => {
if (phase === 'wait' && item.transactionId === 'swap-1') throw failure
return {}
})
const claimSwapOutput = vi.fn().mockImplementation(async ({ handle: item }: { handle: Handle }) => {
if (phase === 'claim' && item.transactionId === 'swap-1') throw failure
return { transactionId: `claim-${item.transactionId}`, amountOut: 80n, amountRemaining: 20n }
})

const results = await submitConcurrentSwaps({ swap, waitForSwapOutput, claimSwapOutput }, [offer, offer])
expect(results.handles).toEqual([handle(1), handle(2)])
expect(results.claims[0]).toMatchObject({ status: 'rejected', reason: failure })
expect(results.claims[1]).toMatchObject({ status: 'fulfilled', value: { amountOut: 80n, amountRemaining: 20n } })
expect(swap).toHaveBeenCalledTimes(2)
expect(waitForSwapOutput).toHaveBeenCalledWith({ handle: handle(1), timeout: 600_000, pollingInterval: 3_000 })
expect(claimSwapOutput).toHaveBeenCalledTimes(phase === 'wait' ? 1 : 2)
})
})

// Validate entry-point guards in a separate process: importing the module in a
// unit test must not hide an accidental CLI execution or missing-key fallback.
describe('concurrent-swaps command', () => {
const script = fileURLToPath(new URL('./concurrent-swaps.ts', import.meta.url))
it.each([
{ args: ['--help'], status: 0, message: 'without flags to preview' },
{ args: ['--execut'], status: 1, message: 'Usage:' },
{ args: [], status: 1, message: 'Set VEIL_E2E_PRIVATE_KEY' },
])('validates arguments before loading an account: $args', ({ args, status, message }) => {
const result = spawnSync('pnpm', ['exec', 'tsx', script, ...args], {
encoding: 'utf8', timeout: 15_000,
env: { ...process.env, VEIL_E2E_PRIVATE_KEY: '' },
})
expect(result.status, result.stderr).toBe(status)
expect(result.stdout + result.stderr).toContain(message)
}, 20_000)
})
Loading
Loading