From 1fc712b76aac3c280f8db94b348ea18cc6b70582 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 14 Aug 2026 15:59:42 +0200 Subject: [PATCH 1/5] fix(multisig-client): reconcile proposal cache with GUARDIAN on sync syncProposals merged the GUARDIAN response into a local Map that was never pruned, so a proposal the server dropped after execution kept being returned as pending. A co-signer who did not execute the tx saw the executed proposal stuck as pending forever. Rebuild the cache from the (authoritative) GUARDIAN response on every sync and skip proposals already consumed by the committed account nonce, mirroring the Rust client's list_proposals. --- .../src/multisig.test.ts | 115 ++++++++++++++++++ .../miden-multisig-client/src/multisig.ts | 49 +++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/packages/miden-multisig-client/src/multisig.test.ts b/packages/miden-multisig-client/src/multisig.test.ts index 7522dd56..dec689ac 100644 --- a/packages/miden-multisig-client/src/multisig.test.ts +++ b/packages/miden-multisig-client/src/multisig.test.ts @@ -955,6 +955,121 @@ describe('Multisig', () => { expect(proposals[0].status).toBe('pending'); }); + it('should prune proposals GUARDIAN no longer reports (executed/canonicalized)', async () => { + const config = { + threshold: 2, + signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)], + guardianCommitment: '0x' + 'c'.repeat(64), + }; + + const multisig = createTestMultisig(config); + + const pendingProposal = { + account_id: '0x' + 'a'.repeat(30), + nonce: 1, + prev_commitment: '0x' + 'b'.repeat(64), + delta_payload: { + tx_summary: { data: 'AQID' }, + signatures: [], + metadata: { + proposal_type: 'add_signer', + target_threshold: 1, + signer_commitments: ['0x' + 'a'.repeat(64)], + description: '', + }, + }, + status: { + status: 'pending', + timestamp: '2024-01-01T00:00:00Z', + proposer_id: '0x' + 'c'.repeat(64), + cosigner_sigs: [ + { + signer_id: '0x' + 'a'.repeat(64), + signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) }, + timestamp: '2024-01-01T00:00:00Z', + }, + ], + }, + }; + + // First sync: GUARDIAN reports the pending proposal, priming the cache. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [pendingProposal] }), + }); + const first = await multisig.syncProposals(); + expect(first.length).toBe(1); + + // Second sync: another signer executed the proposal, so GUARDIAN pruned it + // and now returns an empty list. The cache must reconcile to empty rather + // than keep returning the stale (still-pending-looking) proposal forever. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [] }), + }); + const second = await multisig.syncProposals(); + expect(second).toEqual([]); + expect(multisig.listProposals()).toEqual([]); + }); + + it('should skip proposals already consumed by the committed account nonce', async () => { + const config = { + threshold: 1, + signerCommitments: ['0x' + 'a'.repeat(64)], + guardianCommitment: '0x' + 'c'.repeat(64), + }; + + // Local account is already at nonce 5; a proposal at nonce 5 has been + // consumed even though GUARDIAN still reports it pending (canonicalization + // has not pruned it yet). It must not be returned as pending. + const multisig = new Multisig( + mockedAccount('0x' + 'b'.repeat(64), 5), + config, + guardian, + mockSigner, + mockWebClient, + '0x' + 'a'.repeat(30), + MIDEN_RPC_ENDPOINT, + ); + + const staleProposal = { + account_id: '0x' + 'a'.repeat(30), + nonce: 5, + prev_commitment: '0x' + 'b'.repeat(64), + delta_payload: { + tx_summary: { data: 'AQID' }, + signatures: [], + metadata: { + proposal_type: 'add_signer', + target_threshold: 1, + signer_commitments: ['0x' + 'a'.repeat(64)], + description: '', + }, + }, + status: { + status: 'pending', + timestamp: '2024-01-01T00:00:00Z', + proposer_id: '0x' + 'c'.repeat(64), + cosigner_sigs: [ + { + signer_id: '0x' + 'a'.repeat(64), + signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) }, + timestamp: '2024-01-01T00:00:00Z', + }, + ], + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [staleProposal] }), + }); + + const proposals = await multisig.syncProposals(); + expect(proposals).toEqual([]); + expect(multisig.listProposals()).toEqual([]); + }); + it('should return ready status when enough signatures', async () => { const config = { threshold: 1, // Only 1 signature needed diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index 2cf17b67..a7e61a0b 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -535,14 +535,60 @@ export class Multisig { } } + /** + * The committed nonce of the local account, or null when it cannot be read + * (no account loaded yet, or a nonce larger than a safe integer). Mirrors the + * example app's `currentAccountNonce` helper. + */ + private committedAccountNonce(): number | null { + if (!this.account) { + return null; + } + + try { + const nonce = this.account.nonce().asInt(); + if (nonce > BigInt(Number.MAX_SAFE_INTEGER)) { + return null; + } + return Number(nonce); + } catch { + return null; + } + } + /** * Sync proposals from the GUARDIAN server. + * + * The GUARDIAN response is authoritative for the set of pending proposals: the + * server only reports proposals whose status is still pending and drops them + * once they are executed and canonicalized. The local cache is therefore + * rebuilt to match the response, so a proposal the server has pruned does not + * linger locally and keep showing as pending forever — e.g. a co-signer's + * browser after another signer executed the transaction. This mirrors the Rust + * client's `list_proposals`, which rebuilds its result set from the response on + * every call. + * + * Rebuilding is safe for the offline export/import flow: every proposal is + * pushed to GUARDIAN when it is created (`createProposal`), so an imported + * proposal is still GUARDIAN-tracked and is returned here while pending; it is + * only dropped once GUARDIAN itself stops reporting it (executed / abandoned). */ async syncProposals(): Promise { const deltas = await this.guardian.getDeltaProposals(this._accountId); const factory = this.proposalFactory(); + const committedNonce = this.committedAccountNonce(); + const reconciled = new Map(); + for (const delta of deltas) { + // Skip proposals already consumed by the committed account nonce. GUARDIAN + // may still report them pending until canonicalization prunes them; mirror + // the Rust client's `proposal.nonce <= account.nonce()` staleness filter so + // an executed-but-not-yet-pruned proposal is not shown as pending. + if (committedNonce !== null && delta.nonce <= committedNonce) { + continue; + } + const proposalId = normalizeHexWord( computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data) ); @@ -555,9 +601,10 @@ export class Multisig { ); await this.verifyProposalMetadataBinding(proposal); - this.proposals.set(proposal.id, proposal); + reconciled.set(proposal.id, proposal); } + this.proposals = reconciled; return Array.from(this.proposals.values()); } From c2ac7ed3c71feb89ec1dcd144ac4ecb86ec7eb46 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 14 Aug 2026 16:14:36 +0200 Subject: [PATCH 2/5] refactor(multisig-client): reconcile in place, drop client-side nonce filter Address review: the client-side nonce staleness filter assumed the next-nonce convention and would evict freshly-created proposals under the examples/web same-nonce convention. Nonce-based hiding is a display concern the example apps already handle in filterVisibleProposals, so leave it to callers and keep the client convention-agnostic. Reconcile the cache in place against the authoritative GUARDIAN response instead of wholesale-replacing it: snapshot the ids known before the round-trip and prune only those the server no longer reports, so a proposal created/signed/imported concurrently during the awaits is not dropped. --- .../src/multisig.test.ts | 58 ---------------- .../miden-multisig-client/src/multisig.ts | 67 ++++++++----------- 2 files changed, 28 insertions(+), 97 deletions(-) diff --git a/packages/miden-multisig-client/src/multisig.test.ts b/packages/miden-multisig-client/src/multisig.test.ts index dec689ac..2bd3c610 100644 --- a/packages/miden-multisig-client/src/multisig.test.ts +++ b/packages/miden-multisig-client/src/multisig.test.ts @@ -1012,64 +1012,6 @@ describe('Multisig', () => { expect(multisig.listProposals()).toEqual([]); }); - it('should skip proposals already consumed by the committed account nonce', async () => { - const config = { - threshold: 1, - signerCommitments: ['0x' + 'a'.repeat(64)], - guardianCommitment: '0x' + 'c'.repeat(64), - }; - - // Local account is already at nonce 5; a proposal at nonce 5 has been - // consumed even though GUARDIAN still reports it pending (canonicalization - // has not pruned it yet). It must not be returned as pending. - const multisig = new Multisig( - mockedAccount('0x' + 'b'.repeat(64), 5), - config, - guardian, - mockSigner, - mockWebClient, - '0x' + 'a'.repeat(30), - MIDEN_RPC_ENDPOINT, - ); - - const staleProposal = { - account_id: '0x' + 'a'.repeat(30), - nonce: 5, - prev_commitment: '0x' + 'b'.repeat(64), - delta_payload: { - tx_summary: { data: 'AQID' }, - signatures: [], - metadata: { - proposal_type: 'add_signer', - target_threshold: 1, - signer_commitments: ['0x' + 'a'.repeat(64)], - description: '', - }, - }, - status: { - status: 'pending', - timestamp: '2024-01-01T00:00:00Z', - proposer_id: '0x' + 'c'.repeat(64), - cosigner_sigs: [ - { - signer_id: '0x' + 'a'.repeat(64), - signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) }, - timestamp: '2024-01-01T00:00:00Z', - }, - ], - }, - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ proposals: [staleProposal] }), - }); - - const proposals = await multisig.syncProposals(); - expect(proposals).toEqual([]); - expect(multisig.listProposals()).toEqual([]); - }); - it('should return ready status when enough signatures', async () => { const config = { threshold: 1, // Only 1 signature needed diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index a7e61a0b..6b48db88 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -535,60 +535,41 @@ export class Multisig { } } - /** - * The committed nonce of the local account, or null when it cannot be read - * (no account loaded yet, or a nonce larger than a safe integer). Mirrors the - * example app's `currentAccountNonce` helper. - */ - private committedAccountNonce(): number | null { - if (!this.account) { - return null; - } - - try { - const nonce = this.account.nonce().asInt(); - if (nonce > BigInt(Number.MAX_SAFE_INTEGER)) { - return null; - } - return Number(nonce); - } catch { - return null; - } - } - /** * Sync proposals from the GUARDIAN server. * * The GUARDIAN response is authoritative for the set of pending proposals: the * server only reports proposals whose status is still pending and drops them * once they are executed and canonicalized. The local cache is therefore - * rebuilt to match the response, so a proposal the server has pruned does not - * linger locally and keep showing as pending forever — e.g. a co-signer's - * browser after another signer executed the transaction. This mirrors the Rust - * client's `list_proposals`, which rebuilds its result set from the response on - * every call. + * reconciled to the response — proposals the server no longer reports are + * pruned — so a stale proposal does not linger locally and keep showing as + * pending forever, e.g. a co-signer's browser after another signer executed the + * transaction. Only ids present in the cache when the sync started are eligible + * for pruning, so a proposal created/signed/imported concurrently during the + * awaits below is never dropped. * * Rebuilding is safe for the offline export/import flow: every proposal is * pushed to GUARDIAN when it is created (`createProposal`), so an imported * proposal is still GUARDIAN-tracked and is returned here while pending; it is * only dropped once GUARDIAN itself stops reporting it (executed / abandoned). + * + * Nonce-based staleness hiding — a proposal the account has already advanced + * past, which GUARDIAN may still briefly report as pending before it prunes it + * — is intentionally left to callers' own visible-proposal filter (see the + * examples' `filterVisibleProposals`). The proposal `nonce` field has no single + * cross-app convention (some callers store the pre-execution account nonce, + * others the next nonce), so the client cannot safely filter on it here. */ async syncProposals(): Promise { + // Snapshot the ids known before the network round-trip so only these are + // eligible for pruning; anything added concurrently below is preserved. + const knownIds = new Set(this.proposals.keys()); + const deltas = await this.guardian.getDeltaProposals(this._accountId); const factory = this.proposalFactory(); - const committedNonce = this.committedAccountNonce(); - const reconciled = new Map(); - + const reportedIds = new Set(); for (const delta of deltas) { - // Skip proposals already consumed by the committed account nonce. GUARDIAN - // may still report them pending until canonicalization prunes them; mirror - // the Rust client's `proposal.nonce <= account.nonce()` staleness filter so - // an executed-but-not-yet-pruned proposal is not shown as pending. - if (committedNonce !== null && delta.nonce <= committedNonce) { - continue; - } - const proposalId = normalizeHexWord( computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data) ); @@ -601,10 +582,18 @@ export class Multisig { ); await this.verifyProposalMetadataBinding(proposal); - reconciled.set(proposal.id, proposal); + this.proposals.set(proposal.id, proposal); + reportedIds.add(proposal.id); + } + + // Prune proposals GUARDIAN no longer reports (executed / canonicalized / + // abandoned) so they do not linger as stale pending entries. + for (const id of knownIds) { + if (!reportedIds.has(id)) { + this.proposals.delete(id); + } } - this.proposals = reconciled; return Array.from(this.proposals.values()); } From dcf0f676c57283810b88a179e61cea224f321596 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 14 Aug 2026 16:21:19 +0200 Subject: [PATCH 3/5] test(multisig-client): guard against over-pruning; clarify import invariant Add a test that a proposal GUARDIAN still reports survives the reconcile (catches an unconditional-delete regression), and make the doc comment state explicitly that the import-flow guarantee is transitive through the creator's createProposal push, not the importer. --- .../src/multisig.test.ts | 56 +++++++++++++++++++ .../miden-multisig-client/src/multisig.ts | 11 ++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/miden-multisig-client/src/multisig.test.ts b/packages/miden-multisig-client/src/multisig.test.ts index 2bd3c610..c2b03eb4 100644 --- a/packages/miden-multisig-client/src/multisig.test.ts +++ b/packages/miden-multisig-client/src/multisig.test.ts @@ -1012,6 +1012,62 @@ describe('Multisig', () => { expect(multisig.listProposals()).toEqual([]); }); + it('should keep proposals GUARDIAN still reports across syncs', async () => { + // Guards against over-pruning: a proposal the server still reports on a + // later sync must survive the reconcile, not be deleted. + const config = { + threshold: 2, + signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)], + guardianCommitment: '0x' + 'c'.repeat(64), + }; + + const multisig = createTestMultisig(config); + + const pendingProposal = { + account_id: '0x' + 'a'.repeat(30), + nonce: 1, + prev_commitment: '0x' + 'b'.repeat(64), + delta_payload: { + tx_summary: { data: 'AQID' }, + signatures: [], + metadata: { + proposal_type: 'add_signer', + target_threshold: 1, + signer_commitments: ['0x' + 'a'.repeat(64)], + description: '', + }, + }, + status: { + status: 'pending', + timestamp: '2024-01-01T00:00:00Z', + proposer_id: '0x' + 'c'.repeat(64), + cosigner_sigs: [ + { + signer_id: '0x' + 'a'.repeat(64), + signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) }, + timestamp: '2024-01-01T00:00:00Z', + }, + ], + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [pendingProposal] }), + }); + const first = await multisig.syncProposals(); + expect(first.length).toBe(1); + + // GUARDIAN still reports the same pending proposal on the next sync. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [pendingProposal] }), + }); + const second = await multisig.syncProposals(); + expect(second.length).toBe(1); + expect(multisig.listProposals().length).toBe(1); + }); + it('should return ready status when enough signatures', async () => { const config = { threshold: 1, // Only 1 signature needed diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index 6b48db88..8b6c8d78 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -548,10 +548,13 @@ export class Multisig { * for pruning, so a proposal created/signed/imported concurrently during the * awaits below is never dropped. * - * Rebuilding is safe for the offline export/import flow: every proposal is - * pushed to GUARDIAN when it is created (`createProposal`), so an imported - * proposal is still GUARDIAN-tracked and is returned here while pending; it is - * only dropped once GUARDIAN itself stops reporting it (executed / abandoned). + * Reconciling is safe for the offline export/import flow. `importProposal` + * itself does not push to GUARDIAN, but the only way to create a proposal is + * `createProposal`, which pushes to GUARDIAN before caching — so by the time a + * proposal is exported, shared over a side channel, and imported elsewhere, its + * creator has already registered it with GUARDIAN. An imported proposal is thus + * GUARDIAN-tracked and returned here while pending; it is only dropped once + * GUARDIAN itself stops reporting it (executed / abandoned). * * Nonce-based staleness hiding — a proposal the account has already advanced * past, which GUARDIAN may still briefly report as pending before it prunes it From 6c3ef542798e052688161769c3fae721394afdaf Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 14 Aug 2026 16:26:34 +0200 Subject: [PATCH 4/5] docs(multisig-client): clarify listProposals cache semantics and Rust parity Address review: document that listProposals() returns the last sync's reconciled set (not an ever-growing history), reword the README sync section to say it reconciles/prunes, and note the nonce-filter TS/Rust surface difference is intentional (the shared client cannot assume a single nonce convention). --- packages/miden-multisig-client/README.md | 2 +- packages/miden-multisig-client/src/multisig.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/miden-multisig-client/README.md b/packages/miden-multisig-client/README.md index 0b8ebca0..e622d137 100644 --- a/packages/miden-multisig-client/README.md +++ b/packages/miden-multisig-client/README.md @@ -200,7 +200,7 @@ console.log('Signatures:', signedProposal.signatures.length); ### Sync Proposals -Fetches proposals from the GUARDIAN server and updates local state: +Fetches proposals from the GUARDIAN server and reconciles local state — proposals GUARDIAN no longer reports (executed, canonicalized, or abandoned) are pruned from the cache: ```typescript const proposals = await multisig.syncProposals(); diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index 8b6c8d78..7b8d34d6 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -559,9 +559,12 @@ export class Multisig { * Nonce-based staleness hiding — a proposal the account has already advanced * past, which GUARDIAN may still briefly report as pending before it prunes it * — is intentionally left to callers' own visible-proposal filter (see the - * examples' `filterVisibleProposals`). The proposal `nonce` field has no single - * cross-app convention (some callers store the pre-execution account nonce, - * others the next nonce), so the client cannot safely filter on it here. + * examples' `filterVisibleProposals`). The Rust client applies a + * `proposal.nonce <= account.nonce()` filter directly, but it owns a single + * nonce convention end to end; this shared client serves callers that disagree + * on what the proposal `nonce` means (some store the pre-execution account + * nonce, others the next nonce), so it cannot safely apply that comparison here + * and defers it to the caller. This is an intentional TS/Rust surface difference. */ async syncProposals(): Promise { // Snapshot the ids known before the network round-trip so only these are @@ -601,7 +604,11 @@ export class Multisig { } /** - * List all known proposals + * Returns the proposals cached by the most recent {@link syncProposals} call. + * + * This is that sync's reconciled set, not a durable log: proposals GUARDIAN no + * longer reports were pruned, so do not treat the result as an ever-growing + * history of every proposal ever seen. */ listProposals(): Proposal[] { return Array.from(this.proposals.values()); From 703576a38f97e5e58c13a1c333b80093b94ad98f Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 14 Aug 2026 16:36:46 +0200 Subject: [PATCH 5/5] fix(multisig-client): only prune proposals GUARDIAN previously reported Address review: track the ids GUARDIAN returned on the last sync and prune only those it now omits. A proposal the client created or imported but that GUARDIAN has not yet reported (e.g. read-your-writes lag right after createProposal, or an imported-but-not-yet-synced proposal) is no longer evicted. Subsumes the earlier pre-await snapshot guard. --- .../src/multisig.test.ts | 49 +++++++++++++++++++ .../miden-multisig-client/src/multisig.ts | 41 ++++++++-------- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/packages/miden-multisig-client/src/multisig.test.ts b/packages/miden-multisig-client/src/multisig.test.ts index c2b03eb4..c6ce8aca 100644 --- a/packages/miden-multisig-client/src/multisig.test.ts +++ b/packages/miden-multisig-client/src/multisig.test.ts @@ -1068,6 +1068,55 @@ describe('Multisig', () => { expect(multisig.listProposals().length).toBe(1); }); + it('should not prune a locally-created proposal GUARDIAN has not reported yet', async () => { + // createProposal pushes to GUARDIAN then caches. If GUARDIAN's + // read-your-writes lags and the immediately-following sync omits the + // just-pushed proposal, it must NOT be evicted: GUARDIAN never reported it + // to this client, so it is not a prune candidate. + const config = { + threshold: 1, + signerCommitments: ['0x' + 'a'.repeat(64)], + guardianCommitment: '0x' + 'c'.repeat(64), + }; + const multisig = createTestMultisig(config); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + delta: { + account_id: '0x' + 'a'.repeat(30), + nonce: 1, + prev_commitment: '0x' + 'b'.repeat(64), + delta_payload: { tx_summary: { data: 'AQID' }, signatures: [] }, + status: { + status: 'pending', + timestamp: '2024-01-01T00:00:00Z', + proposer_id: '0x' + 'c'.repeat(64), + cosigner_sigs: [], + }, + }, + commitment: '0x' + 'c'.repeat(64), + }), + }); + const created = await multisig.createProposal(1, 'AQID', { + proposalType: 'add_signer', + targetThreshold: 1, + targetSignerCommitments: ['0x' + 'a'.repeat(64)], + description: '', + }); + expect(multisig.listProposals().length).toBe(1); + + // GUARDIAN's next getDeltaProposals lags and returns []. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ proposals: [] }), + }); + const synced = await multisig.syncProposals(); + expect(synced.length).toBe(1); + expect(synced[0].id).toBe(created.id); + expect(multisig.listProposals().length).toBe(1); + }); + it('should return ready status when enough signatures', async () => { const config = { threshold: 1, // Only 1 signature needed diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index 7b8d34d6..b613a21d 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -160,6 +160,10 @@ export class Multisig { private readonly _accountId: string; private readonly midenRpcEndpoint: string; private proposals: Map = new Map(); + // Proposal ids GUARDIAN returned on the most recent syncProposals. Only these + // are eligible for pruning on the next sync, so a locally created/imported + // proposal GUARDIAN has not yet reported is never evicted (see syncProposals). + private lastReportedProposalIds: Set = new Set(); constructor( account: Account, @@ -541,20 +545,19 @@ export class Multisig { * The GUARDIAN response is authoritative for the set of pending proposals: the * server only reports proposals whose status is still pending and drops them * once they are executed and canonicalized. The local cache is therefore - * reconciled to the response — proposals the server no longer reports are - * pruned — so a stale proposal does not linger locally and keep showing as - * pending forever, e.g. a co-signer's browser after another signer executed the - * transaction. Only ids present in the cache when the sync started are eligible - * for pruning, so a proposal created/signed/imported concurrently during the - * awaits below is never dropped. + * reconciled to the response — a proposal GUARDIAN reported on a previous sync + * but no longer reports is pruned — so a stale proposal does not linger locally + * and keep showing as pending forever, e.g. a co-signer's browser after another + * signer executed the transaction. * - * Reconciling is safe for the offline export/import flow. `importProposal` - * itself does not push to GUARDIAN, but the only way to create a proposal is - * `createProposal`, which pushes to GUARDIAN before caching — so by the time a - * proposal is exported, shared over a side channel, and imported elsewhere, its - * creator has already registered it with GUARDIAN. An imported proposal is thus - * GUARDIAN-tracked and returned here while pending; it is only dropped once - * GUARDIAN itself stops reporting it (executed / abandoned). + * Only proposals GUARDIAN has actually reported are eligible for pruning. A + * proposal that is in the cache but that GUARDIAN has not (yet) returned in a + * response is left untouched. This keeps two flows correct: (1) a + * `createProposal` immediately followed by a sync is not evicted if GUARDIAN's + * read-your-writes lags and omits the just-pushed proposal; (2) the offline + * export/import flow — `importProposal` caches a proposal this client has not + * synced yet — is not evicted before GUARDIAN first reports it. A proposal is + * only dropped once GUARDIAN reported it and then stopped (executed / abandoned). * * Nonce-based staleness hiding — a proposal the account has already advanced * past, which GUARDIAN may still briefly report as pending before it prunes it @@ -567,10 +570,6 @@ export class Multisig { * and defers it to the caller. This is an intentional TS/Rust surface difference. */ async syncProposals(): Promise { - // Snapshot the ids known before the network round-trip so only these are - // eligible for pruning; anything added concurrently below is preserved. - const knownIds = new Set(this.proposals.keys()); - const deltas = await this.guardian.getDeltaProposals(this._accountId); const factory = this.proposalFactory(); @@ -592,13 +591,15 @@ export class Multisig { reportedIds.add(proposal.id); } - // Prune proposals GUARDIAN no longer reports (executed / canonicalized / - // abandoned) so they do not linger as stale pending entries. - for (const id of knownIds) { + // Prune proposals GUARDIAN reported before but no longer reports (executed / + // canonicalized / abandoned). Proposals GUARDIAN has never reported to this + // client (freshly created, or imported and not yet synced) are left alone. + for (const id of this.lastReportedProposalIds) { if (!reportedIds.has(id)) { this.proposals.delete(id); } } + this.lastReportedProposalIds = reportedIds; return Array.from(this.proposals.values()); }