@openzeppelin/miden-multisig-client 0.18.0-rc.1 → 0.18.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/multisig.ts CHANGED
@@ -44,7 +44,10 @@ import {
44
44
  chainAnchorFromBase64,
45
45
  chainAnchorToBase64,
46
46
  executeForSummary,
47
- executeForSummaryAt,
47
+ executeForSummaryAtTip,
48
+ isStaleChainError,
49
+ prepareTipExecution,
50
+ syncToBoundBlock,
48
51
  summaryApprovalExpirationBlockNum,
49
52
  summarySalt,
50
53
  buildUpdateSignersTransactionRequest,
@@ -157,7 +160,7 @@ export interface CreateProposalOptions {
157
160
  /** Proposal nonce; defaults to `Date.now()`. */
158
161
  nonce?: number;
159
162
  /**
160
- * Blocks after the proposal's anchor block by which the transaction must be
163
+ * Blocks after the block the proposal binds by which the transaction must be
161
164
  * included; past that the approvers' signatures no longer authorize it. The
162
165
  * summary binds it, so the executing party can neither shorten nor extend it.
163
166
  * Omitted, the approval never expires (the upstream default).
@@ -808,11 +811,12 @@ export class Multisig {
808
811
  * Every synced proposal's metadata is checked against its signed summary
809
812
  * and the outcome is recorded in {@link Proposal.verification}. One that
810
813
  * fails is still cached and returned, so a single stale or corrupt
811
- * proposal cannot hide the others (issue #462: once the node prunes a
812
- * proposal's anchor block its re-execution fails for everyone). `failed`
813
- * with `retryable: true` means a transient node error, worth syncing
814
- * again; `retryable: false` means the proposal cannot be reproduced and
815
- * must be re-proposed. `signProposal` and `executeProposal` re-verify and
814
+ * proposal cannot hide the others (issue #462). Verification re-executes
815
+ * each proposal at the chain tip, so the Miden client is synced once first.
816
+ * `failed` with `retryable: true` means a transient node error or chain
817
+ * state this client had not caught up with, worth syncing again;
818
+ * `retryable: false` means the proposal cannot be reproduced and must be
819
+ * re-proposed. `signProposal` and `executeProposal` re-verify and
816
820
  * refuse a failed proposal. A failed proposal still counts as reported, so
817
821
  * it is pruned like any other once GUARDIAN stops listing it.
818
822
  *
@@ -852,6 +856,14 @@ export class Multisig {
852
856
  const factory = this.proposalFactory();
853
857
 
854
858
  const reported = new Map<string, { delta: (typeof deltas)[number]; verified: Proposal }>();
859
+ if (deltas.length > 0) {
860
+ // Verification re-executes at the store's sync height; bring it to the
861
+ // tip once for the whole listing. Best effort: a node outage then shows
862
+ // up on each proposal as a retryable failure instead of failing the sync.
863
+ await this.syncChain().catch((error) =>
864
+ console.warn('Could not sync the Miden client before verifying proposals', error),
865
+ );
866
+ }
855
867
  for (const delta of deltas) {
856
868
  const proposalId = normalizeHexWord(
857
869
  computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
@@ -1972,6 +1984,8 @@ export class Multisig {
1972
1984
 
1973
1985
  async signProposal(proposalId: string): Promise<Proposal> {
1974
1986
  const normalizedProposalId = normalizeHexWord(proposalId);
1987
+ // Verification re-executes the proposal at the store's sync height.
1988
+ await this.syncChain();
1975
1989
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
1976
1990
  if (!existingProposal) {
1977
1991
  throw new Error(`Proposal not found: ${proposalId}`);
@@ -2019,9 +2033,14 @@ export class Multisig {
2019
2033
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
2020
2034
  }
2021
2035
 
2036
+ /**
2037
+ * Builds the final, fully signed request for a ready proposal, for a caller
2038
+ * that proves and submits it with its own pipeline. The request declares the
2039
+ * block its summary binds, so execute it at the chain tip, without an
2040
+ * anchor, on a client synced to at least that block.
2041
+ */
2022
2042
  async createTransactionProposalRequest(proposalId: string): Promise<TransactionRequest> {
2023
- const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
2024
- anchor.free();
2043
+ const { finalRequest } = await this.prepareProposalExecution(proposalId);
2025
2044
  return finalRequest;
2026
2045
  }
2027
2046
 
@@ -2031,25 +2050,19 @@ export class Multisig {
2031
2050
  * @param proposalId - The proposal commitment/ID
2032
2051
  */
2033
2052
  async executeProposal(proposalId: string): Promise<void> {
2034
- const { metadata, finalRequest, proposal, anchor } =
2035
- await this.prepareProposalExecution(proposalId);
2036
-
2037
- try {
2038
- if (metadata.proposalType === 'switch_guardian') {
2039
- // #417: import notes embedded in pending proposals from the old
2040
- // GUARDIAN. Must run before the switch executes and repoints;
2041
- // best-effort and bounded — see preservePreSwitchProposalNotes.
2042
- await this.preservePreSwitchProposalNotes();
2043
- }
2053
+ const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
2044
2054
 
2045
- // Execute at the proposal's anchored reference block, so the summary the
2046
- // cosigners signed reproduces exactly. The anchor was already checked
2047
- // against the summary's block commitment during binding verification.
2048
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
2049
- } finally {
2050
- anchor.free();
2055
+ if (metadata.proposalType === 'switch_guardian') {
2056
+ // #417: import notes embedded in pending proposals from the old
2057
+ // GUARDIAN. Must run before the switch executes and repoints;
2058
+ // best-effort and bounded — see preservePreSwitchProposalNotes.
2059
+ await this.preservePreSwitchProposalNotes();
2051
2060
  }
2052
2061
 
2062
+ // Execute at the chain tip. The request declares the block the signed
2063
+ // summary binds, so the summary the cosigners signed reproduces there.
2064
+ await this.submitAtTip(finalRequest);
2065
+
2053
2066
  if (metadata.proposalType === 'switch_guardian') {
2054
2067
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
2055
2068
  throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
@@ -2109,11 +2122,12 @@ export class Multisig {
2109
2122
  * Submit an integration-built transaction (advice already injected). Mirrors
2110
2123
  * the Rust `submit_transaction`; used by the custom proposal producer flow
2111
2124
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
2112
- * The transaction is executed at the proposal's anchored reference block,
2113
- * since the collected signatures only authorize the summary produced there.
2125
+ * The transaction is executed at the chain tip, so the request has to declare
2126
+ * the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
2114
2127
  */
2115
2128
  async submitTransaction(proposalId: string, request: TransactionRequest): Promise<void> {
2116
2129
  const normalizedProposalId = normalizeHexWord(proposalId);
2130
+ await this.syncChain();
2117
2131
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
2118
2132
  const existing = this.getLocalProposal(proposalId);
2119
2133
  const proposal = this.proposalFactory().fromDelta(
@@ -2135,11 +2149,40 @@ export class Multisig {
2135
2149
  `Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
2136
2150
  );
2137
2151
  }
2138
-
2139
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
2140
2152
  } finally {
2141
2153
  anchor.free();
2142
2154
  }
2155
+ await this.submitAtTip(request);
2156
+ }
2157
+
2158
+ /**
2159
+ * Executes `request` at the chain tip, then proves, submits and applies it,
2160
+ * syncing first when this client is still below the block the request binds.
2161
+ */
2162
+ private async submitAtTip(request: TransactionRequest): Promise<void> {
2163
+ const webClient = await this.getRawClient();
2164
+ await prepareTipExecution(webClient, request, () =>
2165
+ retryRpcRead(() => webClient.syncState(), this.rpcConfig),
2166
+ );
2167
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
2168
+ }
2169
+
2170
+ /**
2171
+ * Brings the Miden client to the chain tip before a proposal is re-executed:
2172
+ * an execution loads foreign accounts, the fee faucet among them, at the
2173
+ * store's sync height, which a node prunes about 50 blocks later.
2174
+ */
2175
+ private async syncChain(): Promise<void> {
2176
+ const webClient = await this.getRawClient();
2177
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
2178
+ }
2179
+
2180
+ /** {@link syncToBoundBlock} with this client's RPC retry policy. */
2181
+ private async syncToBoundBlock(boundBlockNum: number): Promise<void> {
2182
+ const webClient = await this.getRawClient();
2183
+ await syncToBoundBlock(webClient, boundBlockNum, () =>
2184
+ retryRpcRead(() => webClient.syncState(), this.rpcConfig),
2185
+ );
2143
2186
  }
2144
2187
 
2145
2188
  /**
@@ -2203,6 +2246,8 @@ export class Multisig {
2203
2246
  transactionRequestBytes: Uint8Array,
2204
2247
  ): Promise<AdviceMap> {
2205
2248
  const normalizedProposalId = normalizeHexWord(proposalId);
2249
+ // The binding probe re-executes the producer's request at the store's sync height.
2250
+ await this.syncChain();
2206
2251
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
2207
2252
  const existing = this.getLocalProposal(proposalId);
2208
2253
  const proposal = this.proposalFactory().fromDelta(
@@ -2237,13 +2282,11 @@ export class Multisig {
2237
2282
 
2238
2283
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
2239
2284
 
2240
- // Probe at the proposal's anchored reference block: the signed summary
2241
- // binds that block's commitment, so probing at the local sync height would
2242
- // never reproduce it. The anchor arrives from an untrusted party via
2243
- // GUARDIAN, so its block commitment is checked against the signed summary
2244
- // before executing against it.
2285
+ // The anchor arrives from an untrusted party via GUARDIAN, so its block
2286
+ // commitment is checked against the signed summary: it has to name the
2287
+ // block the summary binds. The probe itself runs at the chain tip, where
2288
+ // the request's declared bound block reproduces the signed summary.
2245
2289
  const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
2246
- let derivedCommitmentHex: string;
2247
2290
  try {
2248
2291
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
2249
2292
  const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
@@ -2252,12 +2295,12 @@ export class Multisig {
2252
2295
  `Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
2253
2296
  );
2254
2297
  }
2255
- const webClient = await this.getRawClient();
2256
- const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
2257
- derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
2258
2298
  } finally {
2259
2299
  anchor.free();
2260
2300
  }
2301
+ const webClient = await this.getRawClient();
2302
+ const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
2303
+ const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
2261
2304
  if (derivedCommitmentHex !== signedCommitmentHex) {
2262
2305
  throw new Error(
2263
2306
  `Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
@@ -2396,14 +2439,13 @@ export class Multisig {
2396
2439
  }
2397
2440
 
2398
2441
  /**
2399
- * The returned `anchor` is the block the request has to execute at; the
2400
- * caller owns it and frees it once submitted.
2442
+ * The returned request declares the block the signed summary binds, and
2443
+ * executes at the chain tip.
2401
2444
  */
2402
2445
  private async prepareProposalExecution(proposalId: string): Promise<{
2403
2446
  finalRequest: TransactionRequest;
2404
2447
  metadata: ProposalMetadata;
2405
2448
  proposal: Proposal;
2406
- anchor: ChainAnchor;
2407
2449
  }> {
2408
2450
  const proposal = this.getLocalProposal(proposalId);
2409
2451
  if (!proposal) {
@@ -2411,6 +2453,8 @@ export class Multisig {
2411
2453
  }
2412
2454
 
2413
2455
  this.proposalFactory().assertAccountId(proposal.accountId);
2456
+ // Verification and execution run at the store's sync height.
2457
+ await this.syncChain();
2414
2458
  await this.verifyProposalMetadataBinding(proposal);
2415
2459
 
2416
2460
  const metadata = proposal.metadata;
@@ -2560,17 +2604,21 @@ export class Multisig {
2560
2604
  }
2561
2605
 
2562
2606
  const anchor = this.requireProposalAnchor(proposalId, metadata);
2607
+ let binding: ProposalRequestBinding;
2563
2608
  try {
2564
- const finalRequest = await this.buildTransactionRequestFromMetadata(
2565
- metadata,
2566
- proposalRequestBinding(txSummary, anchor, saltHex),
2567
- adviceMap,
2568
- );
2569
- return { finalRequest, metadata, proposal, anchor };
2570
- } catch (error) {
2609
+ binding = proposalRequestBinding(txSummary, anchor, saltHex);
2610
+ } finally {
2571
2611
  anchor.free();
2572
- throw error;
2573
2612
  }
2613
+ // A switch_guardian proposal is verified without a rebuild, so this may be
2614
+ // the first time this client needs the chain at the bound block.
2615
+ await this.syncToBoundBlock(binding.boundBlockNum);
2616
+ const finalRequest = await this.buildTransactionRequestFromMetadata(
2617
+ metadata,
2618
+ binding,
2619
+ adviceMap,
2620
+ );
2621
+ return { finalRequest, metadata, proposal };
2574
2622
  }
2575
2623
 
2576
2624
  /**
@@ -2655,6 +2703,10 @@ export class Multisig {
2655
2703
 
2656
2704
  const proposal = this.proposalFactory().fromExported(exported);
2657
2705
 
2706
+ // Verification re-executes every built-in type at the store's sync height.
2707
+ if (proposal.metadata.proposalType !== 'custom') {
2708
+ await this.syncChain();
2709
+ }
2658
2710
  await this.verifyProposalMetadataBinding(proposal);
2659
2711
  this.proposals.set(proposal.id, proposal);
2660
2712
 
@@ -2695,6 +2747,10 @@ export class Multisig {
2695
2747
  throw new Error('You have already signed this proposal');
2696
2748
  }
2697
2749
 
2750
+ // Verification re-executes every built-in type at the store's sync height.
2751
+ if (proposal.metadata?.proposalType !== 'custom') {
2752
+ await this.syncChain();
2753
+ }
2698
2754
  const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
2699
2755
 
2700
2756
  // Sign the commitment
@@ -2758,7 +2814,7 @@ export class Multisig {
2758
2814
  } catch (error) {
2759
2815
  proposal.verification = {
2760
2816
  status: 'failed',
2761
- retryable: isTransientRpcError(error),
2817
+ retryable: isTransientRpcError(error) || isStaleChainError(error),
2762
2818
  message: error instanceof Error ? error.message : String(error),
2763
2819
  };
2764
2820
  throw error;
@@ -2770,10 +2826,11 @@ export class Multisig {
2770
2826
 
2771
2827
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
2772
2828
 
2773
- // The anchor arrives from an untrusted party via GUARDIAN, so check its
2774
- // block commitment against the one bound into the signed summary before
2775
- // anything executes against it. `ChainAnchor.deserialize` already enforced
2776
- // internal header/chain consistency.
2829
+ // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2830
+ // names the block the signed summary binds: the rebuild below binds the
2831
+ // block it names. Nothing executes against it; the rebuild runs at the
2832
+ // chain tip. `ChainAnchor.deserialize` already enforced internal
2833
+ // header/chain consistency.
2777
2834
  const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2778
2835
  try {
2779
2836
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
@@ -2812,6 +2869,13 @@ export class Multisig {
2812
2869
  return txSummaryCommitment;
2813
2870
  }
2814
2871
 
2872
+ // The rebuild reads the chain's fee faucet from the synced protocol
2873
+ // configuration and executes at the tip, so the store has to have synced
2874
+ // to the block the summary binds; a cosigner that has only just loaded
2875
+ // the account has not.
2876
+ await this.syncToBoundBlock(binding.boundBlockNum);
2877
+ const webClient = await this.getRawClient();
2878
+
2815
2879
  // A consume-notes summary commits to *authenticated* consumption (see
2816
2880
  // ensureNotesAuthenticated), which miden-client decides from this store
2817
2881
  // alone. Put the store in that mode before the rebuild, or a cosigner
@@ -2824,8 +2888,7 @@ export class Multisig {
2824
2888
  }
2825
2889
 
2826
2890
  const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2827
- const webClient = await this.getRawClient();
2828
- const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2891
+ const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
2829
2892
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2830
2893
 
2831
2894
  if (reconstructedCommitment !== txSummaryCommitment) {
@@ -2838,12 +2901,6 @@ export class Multisig {
2838
2901
  }
2839
2902
  }
2840
2903
 
2841
- /**
2842
- * Decodes a proposal's chain anchor. Throws when absent: a proposal without
2843
- * an anchor was created at an unknown reference block, so its signed summary
2844
- * cannot be reproduced, verified, or executed. The caller owns the returned
2845
- * anchor and must `free()` it once done.
2846
- */
2847
2904
  /**
2848
2905
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
2849
2906
  * back to.
@@ -2892,12 +2949,17 @@ export class Multisig {
2892
2949
  return saltHex;
2893
2950
  }
2894
2951
 
2952
+ /**
2953
+ * Decodes a proposal's chain anchor, which names the block its signed summary
2954
+ * binds. Throws when absent: every proposal carries one, so a proposal
2955
+ * without it is malformed and is neither verified nor executed. The caller
2956
+ * owns the returned anchor and must `free()` it once done.
2957
+ */
2895
2958
  private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
2896
2959
  if (!metadata.chainAnchor) {
2897
2960
  throw new Error(
2898
- `Proposal ${proposalId} has no chain anchor; it was created without ` +
2899
- 'chain-anchored execution and its signed summary cannot be reproduced ' +
2900
- 'at the original reference block',
2961
+ `Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2962
+ 'summary binds; it cannot be verified or executed',
2901
2963
  );
2902
2964
  }
2903
2965
  return chainAnchorFromBase64(metadata.chainAnchor);
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  AccountId,
3
- ChainAnchor,
4
3
  MidenClient,
5
4
  TransactionRequest,
6
5
  } from '@miden-sdk/miden-sdk';
@@ -15,15 +14,13 @@ export class ProverWorkflow {
15
14
  private readonly runtime?: RetryRuntime,
16
15
  ) {}
17
16
 
18
- /** Proves, submits, and applies a request at its signed chain anchor. */
19
- async submitAt(
20
- accountId: AccountId,
21
- request: TransactionRequest,
22
- anchor: ChainAnchor,
23
- ): Promise<void> {
24
- const execution = await this.client.transactions.executeRequest(accountId, request, {
25
- anchor,
26
- });
17
+ /**
18
+ * Executes a request at the chain tip, then proves, submits, and applies it.
19
+ * A multisig proposal's request declares the block its summary binds, so the
20
+ * signed summary reproduces at the tip.
21
+ */
22
+ async submit(accountId: AccountId, request: TransactionRequest): Promise<void> {
23
+ const execution = await this.client.transactions.executeRequest(accountId, request);
27
24
  const proof = await proveWithRetry(execution, this.config, this.runtime);
28
25
  const submission = await proof.submit();
29
26
  await submission.apply();
@@ -5,8 +5,10 @@ import type {
5
5
  WasmWebClient,
6
6
  } from '@miden-sdk/miden-sdk';
7
7
  import { AccountId, ChainAnchor, Word } from '@miden-sdk/miden-sdk';
8
+ import { BoundBlockNotDeclaredError } from '../multisig/authArgErrors.js';
8
9
  import { getRawMidenClient } from '../raw-client.js';
9
10
  import { base64ToUint8Array, normalizeHexWord, uint8ArrayToBase64 } from '../utils/encoding.js';
11
+ import { requestBoundBlockNum } from './authArgs.js';
10
12
 
11
13
  /**
12
14
  * Layout of the six user params a multisig auth component binds into the
@@ -37,17 +39,51 @@ export class SummaryAnchorMismatchError extends Error {
37
39
  }
38
40
 
39
41
  /**
40
- * Captures a `ChainAnchor` for the request at the current sync height and
41
- * executes the transaction against it to obtain the summary awaiting
42
- * authorization. The anchor is returned alongside the summary so the proposer
43
- * can ship it with the signed data; cosigners and the executor then reproduce
44
- * the summary with {@link executeForSummaryAt} regardless of their own sync
45
- * height.
42
+ * The Miden client synced and its node still has not produced the block a
43
+ * proposal binds, so the proposal cannot execute at this client's tip yet.
44
+ * Worth retrying once the node catches up.
45
+ */
46
+ export class ChainBehindBoundBlockError extends Error {
47
+ readonly retryable = true;
48
+ readonly syncHeight: number;
49
+ readonly boundBlockNum: number;
50
+
51
+ constructor(details: { syncHeight: number; boundBlockNum: number }) {
52
+ super(
53
+ `the Miden client synced to block ${details.syncHeight}, below block ` +
54
+ `${details.boundBlockNum} the proposal binds; its node has not reached that block yet`,
55
+ );
56
+ this.name = 'ChainBehindBoundBlockError';
57
+ this.syncHeight = details.syncHeight;
58
+ this.boundBlockNum = details.boundBlockNum;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Whether a failed re-execution came from chain state this client can catch up
64
+ * with rather than from the proposal itself: a node that has not reached the
65
+ * bound block yet, or account state the node pruned because this client had
66
+ * not synced recently. Either clears on a later attempt, which syncs first.
67
+ */
68
+ export function isStaleChainError(error: unknown): boolean {
69
+ if (error instanceof ChainBehindBoundBlockError) {
70
+ return true;
71
+ }
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return message.includes('has been pruned');
74
+ }
75
+
76
+ /**
77
+ * Derives the summary awaiting authorization for a proposal the caller is
78
+ * creating now, and captures a `ChainAnchor` at the current sync height to ship
79
+ * with it.
46
80
  *
47
- * The request's auth args bind the block its summary commits to, and this
48
- * package pins that block to the anchor: a proposer builds at the sync height
49
- * the anchor is captured at, and a rebuild passes the anchor's block number.
50
- * The check below is what makes the first half hold.
81
+ * The summary is derived at the chain tip, like every other execution of a
82
+ * multisig proposal (see {@link executeForSummaryAtTip}). The anchor still
83
+ * travels in the proposal: it names the block the request's auth args bind,
84
+ * which is how a rebuild learns that block, and 0.18.0-rc.1 clients re-execute
85
+ * at it. A proposer builds at the sync height the anchor is captured at, and
86
+ * the check below is what makes that hold.
51
87
  */
52
88
  export function executeForSummary(
53
89
  client: MidenClient,
@@ -67,12 +103,11 @@ export async function executeForSummary(
67
103
  txRequest: TransactionRequest,
68
104
  midenRpcEndpoint?: string,
69
105
  ): Promise<{ summary: TransactionSummary; anchor: ChainAnchor }> {
70
- const acc = AccountId.fromHex(accountId);
71
106
  const rawClient = await getRawMidenClient(client, midenRpcEndpoint);
72
107
  const anchor = await rawClient.chainAnchorForRequest(txRequest);
73
108
  let summary: TransactionSummary;
74
109
  try {
75
- summary = await rawClient.executeForSummaryAt(acc, txRequest, anchor);
110
+ summary = await executeForSummaryAtTip(rawClient, accountId, txRequest);
76
111
  } catch (error) {
77
112
  anchor.free();
78
113
  throw error;
@@ -91,11 +126,120 @@ export async function executeForSummary(
91
126
  return { summary, anchor };
92
127
  }
93
128
 
129
+ /**
130
+ * Executes a multisig request at the chain tip to obtain the summary awaiting
131
+ * authorization. This is how cosigners and the executor reproduce a proposal's
132
+ * summary, whatever block they have synced to.
133
+ *
134
+ * Since protocol 0.17 a multisig summary binds the block its auth args name
135
+ * (the bound block), not the block the transaction executes against, so it
136
+ * reproduces at any later tip once the bound block is in the transaction's
137
+ * partial blockchain. The request declares it through `withBlockNumbers`, and
138
+ * foreign accounts, the fee faucet among them, load at the tip. Re-executing at
139
+ * the proposal's anchor instead loads them at the bound block, which a node
140
+ * prunes about 50 blocks later (issue #462).
141
+ *
142
+ * The client has to have synced to at least the bound block. When it has not,
143
+ * this syncs once before executing.
144
+ *
145
+ * @throws BoundBlockNotDeclaredError when the request binds a block in its
146
+ * multisig auth args without declaring it.
147
+ */
148
+ export function executeForSummaryAtTip(
149
+ client: MidenClient,
150
+ accountId: string,
151
+ txRequest: TransactionRequest,
152
+ midenRpcEndpoint: string,
153
+ ): Promise<TransactionSummary>;
154
+ export function executeForSummaryAtTip(
155
+ client: WasmWebClient,
156
+ accountId: string,
157
+ txRequest: TransactionRequest,
158
+ midenRpcEndpoint?: string,
159
+ ): Promise<TransactionSummary>;
160
+ export async function executeForSummaryAtTip(
161
+ client: MidenClient | WasmWebClient,
162
+ accountId: string,
163
+ txRequest: TransactionRequest,
164
+ midenRpcEndpoint?: string,
165
+ ): Promise<TransactionSummary> {
166
+ const rawClient = await getRawMidenClient(client, midenRpcEndpoint);
167
+ await prepareTipExecution(rawClient, txRequest);
168
+ return rawClient.executeForSummary(AccountId.fromHex(accountId), txRequest);
169
+ }
170
+
171
+ /**
172
+ * Gets `client` ready to execute `request` at the chain tip: checks the
173
+ * request declares the block its multisig auth args bind, and syncs to that
174
+ * block (see {@link syncToBoundBlock}).
175
+ *
176
+ * @throws BoundBlockNotDeclaredError when the request binds a block in its
177
+ * multisig auth args without declaring it.
178
+ */
179
+ export async function prepareTipExecution(
180
+ client: WasmWebClient,
181
+ request: TransactionRequest,
182
+ syncState?: () => Promise<unknown>,
183
+ ): Promise<void> {
184
+ const boundBlockNum = requireDeclaredBoundBlock(request);
185
+ if (boundBlockNum !== undefined) {
186
+ await syncToBoundBlock(client, boundBlockNum, syncState);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The block `request`'s multisig auth args bind, after checking the request
192
+ * declares it. `undefined` for a request without multisig auth args, which
193
+ * has no bound block to declare.
194
+ *
195
+ * @throws BoundBlockNotDeclaredError when the block is bound but not declared.
196
+ */
197
+ export function requireDeclaredBoundBlock(request: TransactionRequest): number | undefined {
198
+ const boundBlockNum = requestBoundBlockNum(request);
199
+ if (boundBlockNum !== undefined && !request.blockNumbers().includes(boundBlockNum)) {
200
+ throw new BoundBlockNotDeclaredError(boundBlockNum);
201
+ }
202
+ return boundBlockNum;
203
+ }
204
+
205
+ /**
206
+ * Syncs `client` once when its sync height is below `blockNum`, the block a
207
+ * proposal binds. Execution at a tip below it fails with "requested block N is
208
+ * after transaction reference block M", and a store that has never synced (a
209
+ * cosigner that has only just loaded the account) holds no header to rebuild
210
+ * the request from. `syncState` lets a caller wrap the sync in its own retry
211
+ * policy.
212
+ *
213
+ * This does not make a store that is already past the bound block current. An
214
+ * execution loads foreign accounts, the fee faucet among them, at the store's
215
+ * sync height, which a node prunes about 50 blocks later, so the multisig
216
+ * entry points that re-execute a proposal sync the chain first.
217
+ *
218
+ * @throws ChainBehindBoundBlockError when the node has not reached the block.
219
+ */
220
+ export async function syncToBoundBlock(
221
+ client: WasmWebClient,
222
+ blockNum: number,
223
+ syncState: () => Promise<unknown> = () => client.syncState(),
224
+ ): Promise<void> {
225
+ if ((await client.getSyncHeight()) >= blockNum) {
226
+ return;
227
+ }
228
+ await syncState();
229
+ const syncHeight = await client.getSyncHeight();
230
+ if (syncHeight < blockNum) {
231
+ throw new ChainBehindBoundBlockError({ syncHeight, boundBlockNum: blockNum });
232
+ }
233
+ }
234
+
94
235
  /**
95
236
  * Executes a transaction at the given `ChainAnchor`'s reference block to
96
- * obtain the summary awaiting authorization: the anchored counterpart of
97
- * {@link executeForSummary} for cosigners and executors holding a proposal's
98
- * anchor.
237
+ * obtain the summary awaiting authorization.
238
+ *
239
+ * For a summary that binds the reference block, such as a single-signature
240
+ * one. A multisig proposal's summary binds its bound block instead and is
241
+ * reproduced with {@link executeForSummaryAtTip}: re-executing it at an anchor
242
+ * fails once the node prunes the anchor block's account state.
99
243
  */
100
244
  export function executeForSummaryAt(
101
245
  client: MidenClient,
@@ -134,7 +278,8 @@ export function chainAnchorToBase64(anchor: ChainAnchor): string {
134
278
  * Deserializes a `ChainAnchor` from its base64 wire form. `ChainAnchor`
135
279
  * deserialization validates the header/chain consistency internally, so a
136
280
  * decoded anchor only needs its block commitment checked against the signed
137
- * transaction summary before it is safe to execute against.
281
+ * transaction summary before the block it names is taken as the one the
282
+ * summary binds.
138
283
  */
139
284
  export function chainAnchorFromBase64(anchorBase64: string): ChainAnchor {
140
285
  return ChainAnchor.deserialize(base64ToUint8Array(anchorBase64));
@@ -10,11 +10,17 @@ export {
10
10
  buildConsumeNotesTransactionRequestFromNotes,
11
11
  } from './transaction/consumeNotes.js';
12
12
  export {
13
+ ChainBehindBoundBlockError,
13
14
  chainAnchorBlockNum,
14
15
  chainAnchorFromBase64,
15
16
  chainAnchorToBase64,
16
17
  executeForSummary,
17
18
  executeForSummaryAt,
19
+ executeForSummaryAtTip,
20
+ prepareTipExecution,
21
+ isStaleChainError,
22
+ requireDeclaredBoundBlock,
23
+ syncToBoundBlock,
18
24
  summaryApprovalExpirationBlockNum,
19
25
  summarySalt,
20
26
  SummaryAnchorMismatchError,