Sub-issue of #357. Spike #412 validated the underlying mechanics; this issue productizes them as an SDK recovery primitive.
Context
After recovery on a fresh device, the local store has no note-transport cursor — and in a shared dirty store, another account's sync may have advanced the cursor past notes belonging to the newly recovered account. Recovery therefore needs a full transport rescan.
Spike findings (#412):
- Full device-loss round trip verified live against the testnet transport: a fresh store that tracks the account tag recovers a transport-delivered private note via one
fetch_all_private_notes() drain in ~1.3 s.
- Drains are idempotent; the incremental fetch afterwards is a no-op.
- The upstream drain cannot regress the stored cursor by construction (it persists
max(drain_cursor, stored_cursor)).
- The drain is tag-scoped: a store with no tracked tags drains nothing. The account's note tag must be tracked before draining.
- Transport delivery requires no on-chain transaction (notes arrive
Expected until observed on chain).
Scope
- TS:
drainPrivateNoteBacklog(midenClient) wrapping fetchPrivate({ mode: 'all' }).
- Rust:
drain_private_note_backlog() on MultisigClient, wrapping fetch_all_private_notes().
- Structured
TransportRecoveryReport { status: completed | unavailable | failed, imported, reason? }. Transport-disabled / unreachable must be reported, not thrown — a transport failure must not abort the rest of a recovery flow.
- Catch the upstream convergence-guard error (
PaginationDidNotTerminate, 1000 iterations) and surface it as a retryable failure.
- Load/tag regression tests (these gate whether the drain sees the account at all):
load() (TS) and pull_account (Rust) insert the recovered account, its standard note tag is behaviorally tracked afterwards, and reloading an existing account stays idempotent.
- Rename the misleading Rust helper argument
imported → overwrite while in the area.
Acceptance criteria
Unblocked (spike complete). Ordering: pairs naturally with the other two recovery primitives from the #357 plan.
Sub-issue of #357. Spike #412 validated the underlying mechanics; this issue productizes them as an SDK recovery primitive.
Context
After recovery on a fresh device, the local store has no note-transport cursor — and in a shared dirty store, another account's sync may have advanced the cursor past notes belonging to the newly recovered account. Recovery therefore needs a full transport rescan.
Spike findings (#412):
fetch_all_private_notes()drain in ~1.3 s.max(drain_cursor, stored_cursor)).Expecteduntil observed on chain).Scope
drainPrivateNoteBacklog(midenClient)wrappingfetchPrivate({ mode: 'all' }).drain_private_note_backlog()onMultisigClient, wrappingfetch_all_private_notes().TransportRecoveryReport { status: completed | unavailable | failed, imported, reason? }. Transport-disabled / unreachable must be reported, not thrown — a transport failure must not abort the rest of a recovery flow.PaginationDidNotTerminate, 1000 iterations) and surface it as a retryable failure.load()(TS) andpull_account(Rust) insert the recovered account, its standard note tag is behaviorally tracked afterwards, and reloading an existing account stays idempotent.imported→overwritewhile in the area.Acceptance criteria
Unblocked (spike complete). Ordering: pairs naturally with the other two recovery primitives from the #357 plan.