Skip to content

[Feat] Blinded identity management: reservation, tracking, and recovery - #122

Merged
iamalwaysuncomfortable merged 11 commits into
mainfrom
feat/blinded-identity-reservation
Aug 5, 2026
Merged

[Feat] Blinded identity management: reservation, tracking, and recovery#122
iamalwaysuncomfortable merged 11 commits into
mainfrom
feat/blinded-identity-reservation

Conversation

@iamalwaysuncomfortable

@iamalwaysuncomfortable iamalwaysuncomfortable commented Aug 4, 2026

Copy link
Copy Markdown
Member

Blinded identity management: reservation, tracking inside the swap actions, and recovery from chain.

Rebased onto main after #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_swap asserts each blinded address is used only once. With no identity passed, swap derived one through nextBlindedIdentity, which scans for the first counter the chain does not carry:

for (let counter = start; counter < start + maxScan; counter++) {
  if (!(await isBlindedAddressUsed(client, { address: blindedAddress, program }))) {
    return { counter, blindingFactor, blindedAddress }
  }
}

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. swap and swapMultiHop reserve before submitting and record the resulting handle after; claimSwapOutput marks the identity claimed. Two concurrent client.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 fileBlindedIdentityStore from the new @provablehq/shield-swap-sdk/node entry 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 claimSwapOutput consumes a handle. That is what makes crash recovery real: a process can claim a swap it did not make. SwapHandle holds bigints and JSON.stringify throws 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.

syncBlindedIdentities reconciles the store against the mappings: reserved until the address appears on chain, swapped while its output sits in swap_outputs, claimed once a claim removes that entry. Cheap and safe to call at startup.

reconcileSwapHistory rebuilds a lost store from chain history. A claim is the only public record tying a blinded address to the swap it settled — the swap_outputs entry 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 walks claim_swap_output calls via getProgramCallsPaginated and getTransaction, 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 reports complete: false when it exhausted maxPages rather 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 SwapRecordingError with 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 and reconcileSwapHistory can repair the record.

Opting out needs no flag: pass blindedIdentity to supply your own, or blindedIdentities: undefined to skip tracking for one call. BlindedIdentityStore is two methods, so any backend qualifies.

Also included: resolveDexImports as a client action, and the blinded-identity actions moved one-per-file under actions/blinding/.

Verification

Live on testnet:

suite result
liveSwaps — single hop, forced multi-hop, concurrent 4/4
blindedIdentityStore — reserved → swapped → claimed, rebuild from history, collision differential 6/6
liveLiquidity — mint through burn 8/8

The store suite reads every persistence assertion through a second fileBlindedIdentityStore on 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 shows syncBlindedIdentities alone cannot do it — landing on swapped with no swap id, since the mapping entry the claim consumed is gone.

pnpm vitest run 1384 passed / 225 skipped. pnpm -r exec tsc --noEmit clean, type-level tests clean, loyalty dApp clean, build emits the ./node subpath.

One caveat on the live runs: two of them died in beforeAll on 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:

  • withStore tested truthiness, so a per-call blindedIdentities: undefined had the default injected over the top of it — the same shape as the buildApi spread bug on [Fix] Point the DEX client at the right deployment, and cover the live swap and liquidity journeys #121.
  • Both file stores read ENOTDIR as 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.
  • The changeset and README claimed "without a store, behaviour is exactly as before", which is true only of the standalone swap(client, params) — a composed client tracks by default.

Self-review found one: recordBlindedSwap no-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

  • Forward-probing in syncBlindedIdentities: a probeWindow to discover identities the chain carries but the store does not, unused demotion behind a reservedAt timestamp, and reservation preferring demoted slots over the tip.
  • getUnclaimedSwaps, now that stored handles make the result actionable rather than informational.
  • reserveBlindedIdentity's maxScan bounds the whole scan rather than the unused tail, so an account past 64 identities cannot cold-start without raising it.
  • BlindedIdentityRecord carries no account or program, so one file shared across accounts or deployments silently interleaves them.

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
veil-loyalty-dapp Ready Ready Preview Aug 5, 2026 2:16am

Request Review

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.
@iamalwaysuncomfortable
iamalwaysuncomfortable force-pushed the feat/blinded-identity-reservation branch from 475f400 to 2701945 Compare August 4, 2026 20:33
@iamalwaysuncomfortable
iamalwaysuncomfortable changed the base branch from fix/dex-api-network-hosts to main August 4, 2026 20:34
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.
@iamalwaysuncomfortable iamalwaysuncomfortable changed the title [Feat] Reserve blinded identities so concurrent swaps stop reverting [Feat] Blinded Identity management Aug 4, 2026
@iamalwaysuncomfortable iamalwaysuncomfortable changed the title [Feat] Blinded Identity management [Feat] Blinded Identity Management Aug 4, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/node entrypoint) and store locking for safe in-process concurrency.
  • Updates swap, swapMultiHop, and claimSwapOutput to reserve/record/mark identities when a store is provided, plus new actions (reserveBlindedIdentity, syncBlindedIdentities, recordBlindedSwap, reconcileSwapHistory).
  • Moves resolveDexImports onto 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 reservedswapped/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.

Comment thread packages/shield-swap/src/decorators/shieldSwapActions.ts Outdated
Comment thread packages/shield-swap/src/node.ts
Comment thread .changeset/swap-identity-tracking.md Outdated
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.
@iamalwaysuncomfortable
iamalwaysuncomfortable requested a lite review from Copilot August 5, 2026 02:00
@iamalwaysuncomfortable iamalwaysuncomfortable changed the title [Feat] Blinded Identity Management [Feat] Blinded identity management: reservation, tracking, and recovery Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • SwapRecordingError is referenced as part of the public usage story (README/changeset) but isn’t exported from the package root, so consumers can’t instanceof it 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, but recordBlindedSwap now 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 to BlindedIdentityRecord[] without validating record shape. A partially corrupted file (e.g. missing counter or invalid status) can lead to confusing downstream failures (e.g. NaN counters) 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 SwapRecordingError but doesn’t import it, so the snippet as written won’t typecheck/compile. Once SwapRecordingError is 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@iamalwaysuncomfortable
iamalwaysuncomfortable merged commit c2124ee into main Aug 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants