Skip to content

feat(sdk): preserve proposal-embedded notes on the switch-guardian path (#417) - #441

Open
haseebrabbani wants to merge 1 commit into
mainfrom
417-switch-note-recovery
Open

feat(sdk): preserve proposal-embedded notes on the switch-guardian path (#417)#441
haseebrabbani wants to merge 1 commit into
mainfrom
417-switch-note-recovery

Conversation

@haseebrabbani

Copy link
Copy Markdown
Collaborator

Closes #417 (last active sub-issue of #357; #418 remains deferred).

What

Pending proposals do not survive a guardian switch — the new GUARDIAN is registered with bare account state — so the notes embedded in pending consume-notes proposals are the one recovery source a later device-loss recover_notes / recoverNotes can no longer reach once the old GUARDIAN is left behind. Both SDKs now run the proposal-embedded note import (#415 primitive) on the online switch path, against the old GUARDIAN, before the switch transaction executes.

Why only the proposal import

Per the issue's "document what is skipped" criterion: the transport drain and public backfill are deliberately skipped on the switch path. A switch executes against an intact local store (chain cursor, transport cursor, and note records all persist), and both the note transport and the node are configured independently of the GUARDIAN — a switch loses nothing those primitives rescan. They remain device-loss recovery tools and work unchanged against the new GUARDIAN. The offline switch flow also skips the import (it exists to avoid contacting the GUARDIAN); its docs now say so and point at the manual call.

Design notes

  • The import runs pre-execution because per-proposal summary-binding re-verification only reproduces while the account has not advanced past the state the proposals were built on; it also runs before the best-effort delta push, so the pending listing is read before anything switch-related lands on the old GUARDIAN.
  • Best-effort and bounded: failures and per-note failed/invalid outcomes are warned (this is the last moment the notes are reachable, so "retryable" cannot help), and the whole flow runs under a 30s timeout (PRE_SWITCH_IMPORT_TIMEOUT / PRE_SWITCH_IMPORT_TIMEOUT_MS) — neither guardian client applies request deadlines, so a hung old GUARDIAN must not stall the switch.
  • The switch slice is a named, exhaustively-listed preset in both SDKs (NoteRecoveryOptions::for_guardian_switch(), exported GUARDIAN_SWITCH_RECOVERY_OPTIONS), so adding a recovery strategy forces an explicit decision about the switch path. preserve_pre_switch_proposal_notes / preservePreSwitchProposalNotes are public, and the manual repoint seams (set_guardian_endpoint, setGuardianClient) document calling them first.
  • Drive-by: the TS proposal listing now checks the stale-nonce filter before the per-proposal binding re-execution, matching the Rust listing's order.

Known boundaries

  • Proposals superseded at an earlier nonce (lost a same-nonce race, embedded note still unconsumed) are filtered by the listing and not imported — a pre-existing gap shared with device-loss recovery.
  • The listing binding-verifies every pending proposal (a VM re-execution each) although the import only consumes consume-notes v2; a type pre-filter in the shared listing would take that off the switch critical path.

…th (#417)

Pending proposals do not survive a guardian switch: the new GUARDIAN is
registered with bare account state, so the notes embedded in pending
consume-notes proposals — the one recovery source device-loss recover_notes
can no longer reach once the old GUARDIAN is left behind — must be imported
while the old GUARDIAN still serves them.

Both SDKs now run the proposal-import slice of the recovery flow on the
online switch path, before the switch transaction executes (summary-binding
re-verification only reproduces before the account state advances) and
before anything switch-related lands on the old GUARDIAN. The step is
best-effort and bounded by a 30s timeout: failures, per-note losses, and
timeouts are logged, never raised — an unreachable or hung old GUARDIAN
must not block the switch.

The transport drain and public backfill are deliberately skipped on this
path (the switch runs against an intact local store, and both the note
transport and the node are configured independently of the GUARDIAN), and
the offline switch flow deliberately skips the import (it exists to avoid
contacting the GUARDIAN); both decisions are documented in place. The
manual repoint seams (set_guardian_endpoint / setGuardianClient) document
how to request the same preservation by hand, and the TS switch slice is a
named exhaustive preset (GUARDIAN_SWITCH_RECOVERY_OPTIONS) mirroring
NoteRecoveryOptions::for_guardian_switch so a future strategy cannot
silently join the switch path.

Also hoists the TS stale-nonce check above the per-proposal binding
re-execution, matching the Rust listing's order.
@haseebrabbani
haseebrabbani requested a review from zeljkoX as a code owner August 28, 2026 19:41
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f18d9707-57d9-4467-991d-0b178a716edb


Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeljkoX zeljkoX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automatic preservation on the online SwitchGuardian execution path is the right default: this is the last point where pending proposals on the old Guardian remain available. The public/manual methods are still useful for direct client repointing and optional preparation before offline execution.

I found the following issues to address before merging:

1. Missing continuity coverage

Issue #417 requires proving that a note received before the switch remains consumable afterward. The Rust test only exercises the recovery-options slice against an unreachable Guardian, while the TypeScript test mocks importNotesFromProposals. Neither verifies post-switch consumability.

Recommendation: add one continuity test on at least one SDK (ideally both): a distinct v2 consume_notes proposal → switch → note still in the store and consumable. Do not reuse the shared AQID summary.

2. TypeScript timeout leaves recovery running

Rust’s timeout cancels the recovery future and returns Option<NoteRecoveryReport>. TypeScript returns void and uses Promise.race, so the recovery promise stays active after timeout. That leftover still uses the same Multisig instance — later this.guardian reads can hit the new client after setGuardianClient(), and import/store access can interleave with submitAt() on the shared WASM client.

Recommendation: preservePreSwitchProposalNotes should return Promise<NoteRecoveryReport | undefined> (mirror Rust). Abort in-flight listing/import on timeout instead of flow.catch(() => {}). Do not start submitAt until that work is dead.

3. Aliased TypeScript fixture

The pending delta reuses the switch proposal’s AQID summary, so it inherits cached switch metadata. A real importNotesFromProposals invocation would skip it rather than exercise a consume_notes proposal.

Recommendation: use a distinct summary with genuine consume_notes metadata and note bytes, and assert the import received proposalType: 'consume_notes'.

4. Missing Rust execute-path wiring test

There is no Rust test proving that execute_proposal() invokes preservation specifically for SwitchGuardian. Removing that call would currently go unnoticed.

Recommendation: add an execute_proposal(SwitchGuardian) wiring test that the listing/import ran, matching the TypeScript order test (import → execute → register).

5. Public API documentation

Please document preserve_pre_switch_proposal_notes, NoteRecoveryOptions::for_guardian_switch(), preservePreSwitchProposalNotes, and GUARDIAN_SWITCH_RECOVERY_OPTIONS in both package READMEs and docs/MULTISIG_SDK.md. The docs should clarify:

  • automatic for online switch execution
  • explicit before direct client repointing
  • deliberately skipped during offline execution

No critical issue found, but the timeout lifecycle and missing end-to-end continuity coverage should be resolved before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Run note-recovery primitives on the switch-guardian path

3 participants