[Feat] Blinded identity management: reservation, tracking, and recovery - #122
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Parallel swaps from one local account reverted on finalize. A swap binds to a blinded identity, and the program asserts each blinded address is used only once. With no `blindedIdentity` passed, `swap` derived one via `nextBlindedIdentity`, which returns the first counter the chain does not carry — correct in sequence, wrong in parallel: two concurrent swaps read the same unused counter and the second reverts. Nothing surfaces locally, because at proving time the address genuinely was unused. Disjoint input tokens do not help; the identity is per account, not per token. `reserveBlindedIdentity` records a reservation before returning it and never issues a counter at or below one already stored, so an unconfirmed swap keeps its counter. It advances monotonically from the highest known counter and skips addresses the chain already carries, which recovers a store another process has moved past; an empty store scans from 0, so a lost store costs reads rather than correctness. Local accounts only — a wallet tracks its own identities. Reservations persist through a `BlindedIdentityStore`: `memoryBlindedIdentityStore` by default, or `fileBlindedIdentityStore` on the new `/node` entry point to survive restarts. `recordBlindedSwap` attaches the swap id and `syncBlindedIdentities` promotes each record to `swapped` or `claimed` against the chain, which is what makes pending proceeds recoverable after a crash. Also exposes `resolveDexImports` as a client action, and moves the live swap suite onto reservations — including an assertion that concurrent swaps get distinct addresses.
475f400 to
2701945
Compare
Three decorator-bound functions shared `utils/blinding/store.ts` with the store implementations and types, against the convention every other action follows — one exported function per file under `actions/`. The two that take a client and read chain state are now actions in their own files: `actions/blinding/reserveBlindedIdentity.ts` and `actions/blinding/syncBlindedIdentities.ts`. Their tests move alongside, at `test/actions/blinding/reservation.test.ts`. `recordBlindedSwap` stays in `utils/blinding/store.ts` deliberately. It takes a store rather than a client and never touches the network — it is a store write, and giving it a client parameter it does not use to qualify as an action would be the shape lying about what it does. The store types, the in-memory store, and the lock the two actions share stay with it. No behaviour change; the public surface is identical.
Completes the split: all three blinded-identity actions now live one per file under `actions/blinding/`, matching every other action in the package. `utils/blinding/store.ts` is left with what it is actually about — the store types, the in-memory store, and the lock that serializes read-modify-write across the two actions that mutate it. The signature stays `(store, params)`. It takes no client because it never touches the network, and adding an unused one to look like its siblings would make the shape misdescribe the work.
Blinded identities are derived rather than recorded, and `claim_swap_output` ends with `remove swap_outputs[r2]` — so once a swap is claimed, the claim call is the only public trace tying the identity to it. Its inputs carry what a store needs: blinded address, swap id, both token ids, and the amounts. `reconcileSwapHistory` walks that history with `getProgramCallsPaginated` and `getTransaction`, matches input 1 against the store's identities, takes input 2 as the swap id, and marks the matches `claimed`. Input positions are read from the program's own signature rather than guessed. Kept separate from `syncBlindedIdentities` rather than hidden behind a flag on it. They answer different questions — "is what I recorded still true" against "what happened that I never recorded" — and differ by two orders of magnitude in cost, since one is bounded by the store and the other by chain history. They also have different requirements: this needs no local account or WASM, only public data. Needing a flag to switch cost class is the signal that it is a second operation. It stops as soon as every identity is accounted for, so a current store costs one page, and reports `complete: false` when it exhausted `maxPages` with history remaining rather than implying it reached the end. It cannot surface unclaimed swaps, which have no claim call by definition; that stays with `syncBlindedIdentities` and the mapping. Verified live on testnet: seeded with a blinded address from a real claim, it recovered the swap id, token pair, and amounts in two pages, and the recovered id reads null from `swap_outputs` — as a settled claim should. Documented in the README with a first-run recipe and a section on implementing `BlindedIdentityStore` against any backing store.
Walks one identity through every status the chain can put it in and asserts each transition against what sync reads back from the mappings, not against what the local calls assumed: `reserved` before the swap lands, `swapped` while its output sits in `swap_outputs`, `claimed` once the claim removes that entry. Two things it checks that a memory store could not. Persistence is read through a second `fileBlindedIdentityStore` on the same path every time, because a store that only ever agreed with itself in memory would pass while writing nothing. And the last case throws the file away, rebuilds from a bare reservation, and recovers the swap id from chain history — then shows sync alone cannot do it, landing on `swapped` with no swap id, because the mapping entry the claim consumed is gone. The store is a fresh temp file per run, so reservation starts cold and the counter scan runs for real. Spends one swap and one claim, sized at a thousandth of the balance, with the abort guard so a failure costs one transaction rather than four. Verified live: 4/4 on the first run — 4.6s, 50.8s, 29.6s, 0.4s. The last is short because the claim it looks for is the newest call on the program, so the early exit ends the walk on page one.
Reservation had to be driven by hand — reserve, swap, record — while the default path still derived identities by scanning the chain, which is safe in sequence and reverts in parallel. `swap` and `swapMultiHop` now reserve before submitting and record the handle after; `claimSwapOutput` marks the identity claimed. Two concurrent `client.swap()` calls can no longer collide on one identity, which is what the live concurrency test now exercises directly. All of it is conditional on a configured store. Without one nothing changes: chain-scan derivation, no writes, no new failure modes. An explicit `blindedIdentity` opts out per call, so there is no flag to contradict the config, and wallet accounts are untouched because their identities are derived behind resolve requests the client never sees. Records carry the whole handle now. A claim consumes a handle rather than a swap id, so this is what makes recovery real — a process can claim a swap it did not make. Handles persist through an explicit decimal-string shape rather than a bigint reviver, which cannot round-trip a digit-only string field back to a string. The two failure policies are deliberately opposite. Recording after a landed swap throws `SwapRecordingError` carrying the handle: the transaction is on chain so resubmitting costs more input, but the swap id is knowable only then, and losing it means proceeds nothing can claim. Marking after a claim warns and continues: the funds are already in the account and reconcileSwapHistory repairs the record.
…cy read Adds the negative case: with no store configured, two concurrent derivations of a blinded identity return the same counter, the same address, and the same blinding factor — so two swaps built that way fight over one identity and the second reverts on the uniqueness assert. The contrast case runs the same account at the same moment through a store and gets distinct identities, both unused on chain. Asserted on the derivation rather than by submitting two doomed transactions. The collision is the part this SDK controls; paying two fees to watch the chain reject the second would prove the contract's behaviour instead, and leave a reverted transaction behind. Both cases are read-only and spend nothing. Also removes a duplicate `swap_outputs` read after the claim. `claimed` is by definition the state where sync found that entry gone, so re-reading it only sampled an eventually-consistent value a second time — which disagreed on a live run, failing the step over a claim that had demonstrably succeeded. Live: 6/6 on testnet.
Review of my own change: `recordBlindedSwap` no-ops when the store holds no record for the handle's blinded address. That is right on the manual path — a wallet-derived identity was never in the store — but on the tracked path it meant `recordSwapOrThrow` could report success while writing nothing, losing the swap id exactly as silently as the throw was added to prevent. It now reports whether it matched, and the tracked path throws `SwapRecordingError` when it did not. Only reachable if something replaced the store's contents between the reservation and the record, which is precisely the case worth hearing about rather than absorbing.
There was a problem hiding this comment.
Pull request overview
Adds blinded identity reservation + persistence to make concurrent swaps from a single local account safe, and exposes a Node-only store plus client actions to manage/sync/recover identity state for crash recovery.
Changes:
- Introduces
BlindedIdentityStore(memory + file-backed via new@provablehq/shield-swap-sdk/nodeentrypoint) and store locking for safe in-process concurrency. - Updates
swap,swapMultiHop, andclaimSwapOutputto reserve/record/mark identities when a store is provided, plus new actions (reserveBlindedIdentity,syncBlindedIdentities,recordBlindedSwap,reconcileSwapHistory). - Moves
resolveDexImportsonto the composed client action surface and updates integration tests/docs accordingly.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shield-swap/tsup.config.ts | Builds the new src/node.ts entrypoint. |
| packages/shield-swap/test/utils/blinding/tracking.test.ts | Unit tests for swap recording + claim-marking behaviors. |
| packages/shield-swap/test/utils/blinding/handles.test.ts | Unit tests for persisted handle serialization/deserialization. |
| packages/shield-swap/test/integration/liveSwaps.e2e.test.ts | Uses file-backed store + client.resolveDexImports; asserts concurrent swaps reserve distinct identities. |
| packages/shield-swap/test/integration/liveLiquidity.e2e.test.ts | Switches to client.resolveDexImports. |
| packages/shield-swap/test/integration/blindedIdentityStore.e2e.test.ts | Live testnet lifecycle + persistence + recovery coverage for the identity store. |
| packages/shield-swap/test/actions/blinding/reservation.test.ts | Unit tests for reservation, store behavior, sync behavior, and recording swaps. |
| packages/shield-swap/test/actions/blinding/reconcileSwapHistory.test.ts | Unit tests for history-based recovery of claimed swaps. |
| packages/shield-swap/src/utils/blinding/tracking.ts | Adds SwapRecordingError, recordSwapOrThrow, and markClaimedQuietly. |
| packages/shield-swap/src/utils/blinding/store.ts | Defines store types, memory store, and withStoreLock serialization. |
| packages/shield-swap/src/utils/blinding/handles.ts | Adds persisted-handle wire format + conversions. |
| packages/shield-swap/src/node.ts | Adds Node-only file-backed store implementation. |
| packages/shield-swap/src/index.ts | Exports new blinding reservation/sync/recovery APIs + store types. |
| packages/shield-swap/src/decorators/shieldSwapActions.ts | Wires new actions into composed client and threads store through swap/claim. |
| packages/shield-swap/src/actions/swap/swapMultiHop.ts | Reserves identities and records handles for multi-hop swaps when store is present. |
| packages/shield-swap/src/actions/swap/swap.ts | Reserves identities and records handles for single-hop swaps when store is present. |
| packages/shield-swap/src/actions/swap/claimSwapOutput.ts | Marks identities claimed in the store after a successful claim. |
| packages/shield-swap/src/actions/blinding/syncBlindedIdentities.ts | Adds chain reconciliation to promote reserved → swapped/claimed. |
| packages/shield-swap/src/actions/blinding/reserveBlindedIdentity.ts | Adds store-backed, monotonic identity reservation for local accounts. |
| packages/shield-swap/src/actions/blinding/recordBlindedSwap.ts | Stores swap id + persisted handle against a reservation. |
| packages/shield-swap/src/actions/blinding/reconcileSwapHistory.ts | Recovers claimed swaps by walking claim_swap_output call history. |
| packages/shield-swap/README.md | Documents resolveDexImports action and the identity tracking/store workflow. |
| packages/shield-swap/package.json | Exposes the new ./node export subpath. |
| .changeset/swap-identity-tracking.md | Release note for swap identity tracking changes. |
| .changeset/blinded-identity-reservation.md | Release note for reservation/store APIs and resolveDexImports action. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Three findings, all real. `withStore` tested truthiness, so `blindedIdentities: undefined` — a caller opting one call out of tracking — had the default store injected over the top of it, while the adjacent comment claimed the opt-out worked. It now tests key presence, so an explicit undefined is honoured and only an absent key takes the default. This is the same shape as the `buildApi` spread bug in #121: a truthy check standing in for "was this specified". Pinned by a test that fails against the previous version. Both file stores treated `ENOTDIR` as an absent file. It is a malformed path — a component that is not a directory — so nothing can be stored there, and reading it as absent meant restarting counters at 0, or in the credential store registering a replacement consumer whose key then could not be persisted. Failing at load names the bad path before any of that. An earlier test asserted the weaker behaviour and now asserts this one. The changeset and README claimed "without a store, behaviour is exactly as before". That holds for the standalone `swap(client, params)` but not for a composed client, which gets an in-memory store by default and therefore tracks. Corrected in both, along with what the default does not give you: no persistence, so a restart rescans for its counter and forgets an unclaimed swap.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (5)
packages/shield-swap/src/index.ts:85
SwapRecordingErroris referenced as part of the public usage story (README/changeset) but isn’t exported from the package root, so consumers can’tinstanceofit without deep-importing an internal file.
export {
memoryBlindedIdentityStore,
type BlindedIdentityRecord,
type BlindedIdentityStatus,
type BlindedIdentityStore,
} from './utils/blinding/store.js'
packages/shield-swap/src/actions/blinding/reserveBlindedIdentity.ts:56
- The JSDoc example still uses the old
recordBlindedSwap({ blindedAddress, swapId })signature, butrecordBlindedSwapnow takes{ handle }. As written, this example won’t compile and suggests the wrong API.
* @example
* const identity = await client.reserveBlindedIdentity()
* const handle = await client.swap({ poolKey, tokenInId, amountIn, blindedIdentity: identity })
* await client.recordBlindedSwap({ blindedAddress: identity.blindedAddress, swapId: handle.swapId! })
packages/shield-swap/src/node.ts:67
fileBlindedIdentityStore.load()accepts any JSON array and casts it toBlindedIdentityRecord[]without validating record shape. A partially corrupted file (e.g. missingcounteror invalidstatus) can lead to confusing downstream failures (e.g.NaNcounters) instead of a clear load-time error.
if (!Array.isArray(parsed)) {
throw new Error(`Blinded identity store ${path} does not hold an array of records.`)
}
return parsed as BlindedIdentityRecord[]
packages/shield-swap/src/node.ts:72
writeFile(..., { mode: 0o600 })only applies the mode when the file is created; overwriting an existing file will keep its prior permissions. Since this file links swaps to an account, it’s safer to explicitly enforce 0600 on every save.
save: async (records) => {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(records, null, 2)}\n`, { mode: 0o600 })
},
packages/shield-swap/README.md:512
- The example uses
SwapRecordingErrorbut doesn’t import it, so the snippet as written won’t typecheck/compile. OnceSwapRecordingErroris exported from the package root, import it here to make the example runnable.
```ts
try {
await client.swap({ poolKey, tokenInId, amountIn, imports })
} catch (error) {
if (error instanceof SwapRecordingError) await myBackup.save(error.handle)
throw error
}
One entry per output still sitting in `swap_outputs`, per-token totals, and a handle rebuilt from the store so each entry can be claimed by a process that did not make the swap. That last part is what the stored handles were for: `claimSwapOutput` consumes a whole handle, so a summary without one reports money it cannot help you collect. Reads the mapping rather than trusting stored statuses, so an entry appears exactly when a claim would succeed — a record the store still calls `swapped` whose output was claimed elsewhere is omitted rather than promised. Totals count both sides, because a claim pays the output token and refunds whatever of the input went unfilled. Identities the chain has consumed whose swap id was never recorded are reported as `unresolvable` rather than dropped. Nothing on chain locates their proceeds until a claim exists, so they need reconcileSwapHistory and only after something claims them — saying so is more useful than an empty list that looks like "nothing owed". Six tests, including that the chain overrides a stale stored status, that a reserved identity with no swap id is not mistaken for a lost one, and that claimed records cost no reads.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/shield-swap/src/utils/blinding/handles.ts:154
- fromPersistedHandle’s numeric conversion will also accept non-string values (e.g. a JSON number). That silently reintroduces precision loss for u128-sized fields (the number may already be rounded) instead of failing fast, which defeats the purpose of persisting bigint values as decimal strings.
const toBig = (value: string, field: string): bigint => {
try {
return BigInt(value)
} catch (cause) {
throw new Error(
`Stored handle for swap ${handle.swapId ?? '(no id)'} has a non-numeric ${field}: ${value}`,
{ cause },
)
}
}
packages/shield-swap/src/node.ts:23
- The doc comment says the store file “is written 0600”, but Node’s writeFile({ mode }) only applies the mode reliably when the file is created; it won’t necessarily fix permissions on an existing file. This is a security-sensitive statement, so it should be phrased accurately (or enforce permissions explicitly).
* The file holds blinding factors, which are derivable from the account's view
* key and therefore no more sensitive than it, but do link swaps to the
* account. It is written `0600`, and parent directories are created on first
* save.
An audit of every symbol this PR adds turned up two paths tested only live. `swap`'s tracked path now has offline coverage: that it reserves from the store before submitting, records the swap id and whole handle after, and surfaces `SwapRecordingError` carrying the handle when the second write fails while the transaction is already on chain. These use a view key generated per run, because the shared fixture's placeholder is not a real key — the other cases get away with it by passing an explicit identity, but reservation derives, and derivation is the point here. One of those assertions was wrong and the code was right: recording leaves the status `reserved`. Status tracks what the chain shows, not what the local process just did, so a submitted-but-unconfirmed swap never claims to be settled. Now asserted explicitly, since it is a property worth defending. `getUnclaimedSwaps` had unit tests but no live exercise. The store e2e now checks it on both sides of a real claim: reported as owed and claimable while the output sits in `swap_outputs`, absent once the claim removes it — a stale "still owed" there would send a caller to claim twice. Live: 6/6 on testnet.
Blinded identity management: reservation, tracking inside the swap actions, and recovery from chain.
Rebased onto
mainafter #121 merged, so this diff is only the identity work.The problem
Every private swap binds to a blinded identity — a one-time address derived from the account's view key and a counter — and
shield_swapasserts each blinded address is used only once. With no identity passed,swapderived one throughnextBlindedIdentity, which scans for the first counter the chain does not carry:Correct in sequence, unsafe in parallel: two swaps starting together scan identical chain state, reach the same answer, and the second reverts on finalize once the first consumes it. Nothing surfaces locally, because at proving time the address genuinely was unused — the check and the use are not atomic. Disjoint input tokens do not help; the identity is per account, not per token.
That is now asserted against live chain state rather than argued: two concurrent derivations with no store return the same counter, address, and blinding factor.
What changed
The swap actions track identities themselves.
swapandswapMultiHopreserve before submitting and record the resulting handle after;claimSwapOutputmarks the identity claimed. Two concurrentclient.swap()calls can no longer collide, because reservations serialize and each counter is written before its transaction goes out.A composed client gets an in-memory store by default, so this works with no configuration. That store has no persistence — a restart rescans the chain for its next counter and forgets any unclaimed swap — so long-running callers pass
fileBlindedIdentityStorefrom the new@provablehq/shield-swap-sdk/nodeentry point.Reservation (
reserveBlindedIdentity) records each identity before handing it back and never issues a counter at or below one already stored, so a still-unconfirmed swap keeps its counter. It advances monotonically from the highest known counter and still checks every candidate against the chain, which recovers a store another process has moved past. An empty store scans from 0, so a lost store costs reads rather than correctness. Local accounts only — a wallet derives identities behind resolve requests the client never sees, so its store is left untouched rather than made wrong.Records carry the whole handle, not just the swap id, because
claimSwapOutputconsumes a handle. That is what makes crash recovery real: a process can claim a swap it did not make.SwapHandleholdsbigints andJSON.stringifythrows on those, so handles persist through an explicit decimal-string shape (toPersistedHandle/fromPersistedHandle) rather than a bigint reviver, which cannot round-trip — a digit-only string field would come back a bigint.syncBlindedIdentitiesreconciles the store against the mappings:reserveduntil the address appears on chain,swappedwhile its output sits inswap_outputs,claimedonce a claim removes that entry. Cheap and safe to call at startup.reconcileSwapHistoryrebuilds a lost store from chain history. A claim is the only public record tying a blinded address to the swap it settled — theswap_outputsentry is deleted by the very claim that settles it, and the identity is derived rather than recorded anywhere the account can read. So the action walksclaim_swap_outputcalls viagetProgramCallsPaginatedandgetTransaction, matching input 1 (blinded address) and taking input 2 (swap id), positions read from the program's own signature. It stops as soon as every identity is accounted for, so a current store costs one page, and reportscomplete: falsewhen it exhaustedmaxPagesrather than implying it reached the end. It deliberately cannot surface unclaimed swaps, which have no claim call by definition.Two deliberately opposite failure policies. A store write that fails after a swap lands throws
SwapRecordingErrorwith the handle attached — the transaction is on chain so resubmitting spends more input, but the swap id is knowable only at that moment, so a swallowed failure means proceeds nothing can claim. A store write that fails after a claim warns and continues, because the funds have already landed andreconcileSwapHistorycan repair the record.Opting out needs no flag: pass
blindedIdentityto supply your own, orblindedIdentities: undefinedto skip tracking for one call.BlindedIdentityStoreis two methods, so any backend qualifies.Also included:
resolveDexImportsas a client action, and the blinded-identity actions moved one-per-file underactions/blinding/.Verification
Live on testnet:
liveSwaps— single hop, forced multi-hop, concurrentblindedIdentityStore— reserved → swapped → claimed, rebuild from history, collision differentialliveLiquidity— mint through burnThe store suite reads every persistence assertion through a second
fileBlindedIdentityStoreon the same path, because a store that only agreed with itself in memory would pass while writing nothing. It claims from the stored handle rather than the in-memory one, and its last case discards the file, recovers the swap id from chain history, then showssyncBlindedIdentitiesalone cannot do it — landing onswappedwith no swap id, since the mapping entry the claim consumed is gone.pnpm vitest run1384 passed / 225 skipped.pnpm -r exec tsc --noEmitclean, type-level tests clean, loyalty dApp clean, build emits the./nodesubpath.One caveat on the live runs: two of them died in
beforeAllon the Provable API's intermittent scanner'iss'401 and passed on retry. That flakiness is unrelated to this change and hit three times across the day.Review
Copilot found three issues, all real and all fixed in 1d9ede3:
withStoretested truthiness, so a per-callblindedIdentities: undefinedhad the default injected over the top of it — the same shape as thebuildApispread bug on [Fix] Point the DEX client at the right deployment, and cover the live swap and liquidity journeys #121.ENOTDIRas an absent file. It is a malformed path, so nothing can be stored there; for credentials that meant registering a replacement consumer whose key then could not be persisted.swap(client, params)— a composed client tracks by default.Self-review found one:
recordBlindedSwapno-ops when the store lacks the address, which is right for wallet handles but let the tracked path report success while writing nothing — losing the swap id exactly as silently as the throw exists to prevent. It now reports whether it matched.Each fix carries a test verified to fail against the previous behaviour.
Follow-ups, not in this PR
syncBlindedIdentities: aprobeWindowto discover identities the chain carries but the store does not,unuseddemotion behind areservedAttimestamp, and reservation preferring demoted slots over the tip.getUnclaimedSwaps, now that stored handles make the result actionable rather than informational.reserveBlindedIdentity'smaxScanbounds the whole scan rather than the unused tail, so an account past 64 identities cannot cold-start without raising it.BlindedIdentityRecordcarries no account or program, so one file shared across accounts or deployments silently interleaves them.