@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/dist/multisig.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { GuardianHttpClient } from '@openzeppelin/guardian-client';
8
8
  import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
9
9
  import { Account, AccountId, AdviceMap, FeltArray, Note, NoteExportFormat, NoteFile, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
10
- import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, summaryApprovalExpirationBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
10
+ import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAtTip, isStaleChainError, prepareTipExecution, syncToBoundBlock, summaryApprovalExpirationBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
11
11
  import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
12
12
  import { validateMultisigConfig } from './account/builder.js';
13
13
  import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
@@ -539,11 +539,12 @@ export class Multisig {
539
539
  * Every synced proposal's metadata is checked against its signed summary
540
540
  * and the outcome is recorded in {@link Proposal.verification}. One that
541
541
  * fails is still cached and returned, so a single stale or corrupt
542
- * proposal cannot hide the others (issue #462: once the node prunes a
543
- * proposal's anchor block its re-execution fails for everyone). `failed`
544
- * with `retryable: true` means a transient node error, worth syncing
545
- * again; `retryable: false` means the proposal cannot be reproduced and
546
- * must be re-proposed. `signProposal` and `executeProposal` re-verify and
542
+ * proposal cannot hide the others (issue #462). Verification re-executes
543
+ * each proposal at the chain tip, so the Miden client is synced once first.
544
+ * `failed` with `retryable: true` means a transient node error or chain
545
+ * state this client had not caught up with, worth syncing again;
546
+ * `retryable: false` means the proposal cannot be reproduced and must be
547
+ * re-proposed. `signProposal` and `executeProposal` re-verify and
547
548
  * refuse a failed proposal. A failed proposal still counts as reported, so
548
549
  * it is pruned like any other once GUARDIAN stops listing it.
549
550
  *
@@ -581,6 +582,12 @@ export class Multisig {
581
582
  const deltas = await this.guardian.getDeltaProposals(this._accountId);
582
583
  const factory = this.proposalFactory();
583
584
  const reported = new Map();
585
+ if (deltas.length > 0) {
586
+ // Verification re-executes at the store's sync height; bring it to the
587
+ // tip once for the whole listing. Best effort: a node outage then shows
588
+ // up on each proposal as a retryable failure instead of failing the sync.
589
+ await this.syncChain().catch((error) => console.warn('Could not sync the Miden client before verifying proposals', error));
590
+ }
584
591
  for (const delta of deltas) {
585
592
  const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
586
593
  const existingProposal = this.proposals.get(proposalId);
@@ -1464,6 +1471,8 @@ export class Multisig {
1464
1471
  }
1465
1472
  async signProposal(proposalId) {
1466
1473
  const normalizedProposalId = normalizeHexWord(proposalId);
1474
+ // Verification re-executes the proposal at the store's sync height.
1475
+ await this.syncChain();
1467
1476
  const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
1468
1477
  if (!existingProposal) {
1469
1478
  throw new Error(`Proposal not found: ${proposalId}`);
@@ -1492,9 +1501,14 @@ export class Multisig {
1492
1501
  await this.syncProposals();
1493
1502
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1494
1503
  }
1504
+ /**
1505
+ * Builds the final, fully signed request for a ready proposal, for a caller
1506
+ * that proves and submits it with its own pipeline. The request declares the
1507
+ * block its summary binds, so execute it at the chain tip, without an
1508
+ * anchor, on a client synced to at least that block.
1509
+ */
1495
1510
  async createTransactionProposalRequest(proposalId) {
1496
- const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
1497
- anchor.free();
1511
+ const { finalRequest } = await this.prepareProposalExecution(proposalId);
1498
1512
  return finalRequest;
1499
1513
  }
1500
1514
  /**
@@ -1503,22 +1517,16 @@ export class Multisig {
1503
1517
  * @param proposalId - The proposal commitment/ID
1504
1518
  */
1505
1519
  async executeProposal(proposalId) {
1506
- const { metadata, finalRequest, proposal, anchor } = await this.prepareProposalExecution(proposalId);
1507
- try {
1508
- if (metadata.proposalType === 'switch_guardian') {
1509
- // #417: import notes embedded in pending proposals from the old
1510
- // GUARDIAN. Must run before the switch executes and repoints;
1511
- // best-effort and bounded — see preservePreSwitchProposalNotes.
1512
- await this.preservePreSwitchProposalNotes();
1513
- }
1514
- // Execute at the proposal's anchored reference block, so the summary the
1515
- // cosigners signed reproduces exactly. The anchor was already checked
1516
- // against the summary's block commitment during binding verification.
1517
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
1518
- }
1519
- finally {
1520
- anchor.free();
1521
- }
1520
+ const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
1521
+ if (metadata.proposalType === 'switch_guardian') {
1522
+ // #417: import notes embedded in pending proposals from the old
1523
+ // GUARDIAN. Must run before the switch executes and repoints;
1524
+ // best-effort and bounded — see preservePreSwitchProposalNotes.
1525
+ await this.preservePreSwitchProposalNotes();
1526
+ }
1527
+ // Execute at the chain tip. The request declares the block the signed
1528
+ // summary binds, so the summary the cosigners signed reproduces there.
1529
+ await this.submitAtTip(finalRequest);
1522
1530
  if (metadata.proposalType === 'switch_guardian') {
1523
1531
  if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
1524
1532
  throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
@@ -1564,11 +1572,12 @@ export class Multisig {
1564
1572
  * Submit an integration-built transaction (advice already injected). Mirrors
1565
1573
  * the Rust `submit_transaction`; used by the custom proposal producer flow
1566
1574
  * after `prepareCustomExecution` rebuilds its request with the returned advice.
1567
- * The transaction is executed at the proposal's anchored reference block,
1568
- * since the collected signatures only authorize the summary produced there.
1575
+ * The transaction is executed at the chain tip, so the request has to declare
1576
+ * the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
1569
1577
  */
1570
1578
  async submitTransaction(proposalId, request) {
1571
1579
  const normalizedProposalId = normalizeHexWord(proposalId);
1580
+ await this.syncChain();
1572
1581
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1573
1582
  const existing = this.getLocalProposal(proposalId);
1574
1583
  const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
@@ -1580,11 +1589,34 @@ export class Multisig {
1580
1589
  if (anchorCommitment !== summaryBlockCommitment) {
1581
1590
  throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1582
1591
  }
1583
- await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
1584
1592
  }
1585
1593
  finally {
1586
1594
  anchor.free();
1587
1595
  }
1596
+ await this.submitAtTip(request);
1597
+ }
1598
+ /**
1599
+ * Executes `request` at the chain tip, then proves, submits and applies it,
1600
+ * syncing first when this client is still below the block the request binds.
1601
+ */
1602
+ async submitAtTip(request) {
1603
+ const webClient = await this.getRawClient();
1604
+ await prepareTipExecution(webClient, request, () => retryRpcRead(() => webClient.syncState(), this.rpcConfig));
1605
+ await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
1606
+ }
1607
+ /**
1608
+ * Brings the Miden client to the chain tip before a proposal is re-executed:
1609
+ * an execution loads foreign accounts, the fee faucet among them, at the
1610
+ * store's sync height, which a node prunes about 50 blocks later.
1611
+ */
1612
+ async syncChain() {
1613
+ const webClient = await this.getRawClient();
1614
+ await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
1615
+ }
1616
+ /** {@link syncToBoundBlock} with this client's RPC retry policy. */
1617
+ async syncToBoundBlock(boundBlockNum) {
1618
+ const webClient = await this.getRawClient();
1619
+ await syncToBoundBlock(webClient, boundBlockNum, () => retryRpcRead(() => webClient.syncState(), this.rpcConfig));
1588
1620
  }
1589
1621
  /**
1590
1622
  * Create a proposal from a producer-built transaction the SDK does not model.
@@ -1632,6 +1664,8 @@ export class Multisig {
1632
1664
  */
1633
1665
  async prepareCustomExecution(proposalId, transactionRequestBytes) {
1634
1666
  const normalizedProposalId = normalizeHexWord(proposalId);
1667
+ // The binding probe re-executes the producer's request at the store's sync height.
1668
+ await this.syncChain();
1635
1669
  const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
1636
1670
  const existing = this.getLocalProposal(proposalId);
1637
1671
  const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
@@ -1646,26 +1680,24 @@ export class Multisig {
1646
1680
  const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
1647
1681
  const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
1648
1682
  const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
1649
- // Probe at the proposal's anchored reference block: the signed summary
1650
- // binds that block's commitment, so probing at the local sync height would
1651
- // never reproduce it. The anchor arrives from an untrusted party via
1652
- // GUARDIAN, so its block commitment is checked against the signed summary
1653
- // before executing against it.
1683
+ // The anchor arrives from an untrusted party via GUARDIAN, so its block
1684
+ // commitment is checked against the signed summary: it has to name the
1685
+ // block the summary binds. The probe itself runs at the chain tip, where
1686
+ // the request's declared bound block reproduces the signed summary.
1654
1687
  const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
1655
- let derivedCommitmentHex;
1656
1688
  try {
1657
1689
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
1658
1690
  const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
1659
1691
  if (anchorCommitment !== summaryBlockCommitment) {
1660
1692
  throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
1661
1693
  }
1662
- const webClient = await this.getRawClient();
1663
- const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
1664
- derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1665
1694
  }
1666
1695
  finally {
1667
1696
  anchor.free();
1668
1697
  }
1698
+ const webClient = await this.getRawClient();
1699
+ const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
1700
+ const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
1669
1701
  if (derivedCommitmentHex !== signedCommitmentHex) {
1670
1702
  throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
1671
1703
  }
@@ -1753,8 +1785,8 @@ export class Multisig {
1753
1785
  return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
1754
1786
  }
1755
1787
  /**
1756
- * The returned `anchor` is the block the request has to execute at; the
1757
- * caller owns it and frees it once submitted.
1788
+ * The returned request declares the block the signed summary binds, and
1789
+ * executes at the chain tip.
1758
1790
  */
1759
1791
  async prepareProposalExecution(proposalId) {
1760
1792
  const proposal = this.getLocalProposal(proposalId);
@@ -1762,6 +1794,8 @@ export class Multisig {
1762
1794
  throw new Error(`Proposal not found: ${proposalId}`);
1763
1795
  }
1764
1796
  this.proposalFactory().assertAccountId(proposal.accountId);
1797
+ // Verification and execution run at the store's sync height.
1798
+ await this.syncChain();
1765
1799
  await this.verifyProposalMetadataBinding(proposal);
1766
1800
  const metadata = proposal.metadata;
1767
1801
  // Reject custom proposals before any advice assembly or GUARDIAN ack push:
@@ -1873,14 +1907,18 @@ export class Multisig {
1873
1907
  await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
1874
1908
  }
1875
1909
  const anchor = this.requireProposalAnchor(proposalId, metadata);
1910
+ let binding;
1876
1911
  try {
1877
- const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, proposalRequestBinding(txSummary, anchor, saltHex), adviceMap);
1878
- return { finalRequest, metadata, proposal, anchor };
1912
+ binding = proposalRequestBinding(txSummary, anchor, saltHex);
1879
1913
  }
1880
- catch (error) {
1914
+ finally {
1881
1915
  anchor.free();
1882
- throw error;
1883
1916
  }
1917
+ // A switch_guardian proposal is verified without a rebuild, so this may be
1918
+ // the first time this client needs the chain at the bound block.
1919
+ await this.syncToBoundBlock(binding.boundBlockNum);
1920
+ const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, binding, adviceMap);
1921
+ return { finalRequest, metadata, proposal };
1884
1922
  }
1885
1923
  /**
1886
1924
  * Export a proposal for offline signing
@@ -1950,6 +1988,10 @@ export class Multisig {
1950
1988
  throw new Error('Invalid proposal JSON: missing required fields');
1951
1989
  }
1952
1990
  const proposal = this.proposalFactory().fromExported(exported);
1991
+ // Verification re-executes every built-in type at the store's sync height.
1992
+ if (proposal.metadata.proposalType !== 'custom') {
1993
+ await this.syncChain();
1994
+ }
1953
1995
  await this.verifyProposalMetadataBinding(proposal);
1954
1996
  this.proposals.set(proposal.id, proposal);
1955
1997
  return proposal;
@@ -1982,6 +2024,10 @@ export class Multisig {
1982
2024
  if (alreadySigned) {
1983
2025
  throw new Error('You have already signed this proposal');
1984
2026
  }
2027
+ // Verification re-executes every built-in type at the store's sync height.
2028
+ if (proposal.metadata?.proposalType !== 'custom') {
2029
+ await this.syncChain();
2030
+ }
1985
2031
  const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
1986
2032
  // Sign the commitment
1987
2033
  const signature = await buildGuardianSignatureFromSigner(this.signer, commitmentToSign);
@@ -2033,7 +2079,7 @@ export class Multisig {
2033
2079
  catch (error) {
2034
2080
  proposal.verification = {
2035
2081
  status: 'failed',
2036
- retryable: isTransientRpcError(error),
2082
+ retryable: isTransientRpcError(error) || isStaleChainError(error),
2037
2083
  message: error instanceof Error ? error.message : String(error),
2038
2084
  };
2039
2085
  throw error;
@@ -2042,10 +2088,11 @@ export class Multisig {
2042
2088
  async checkProposalMetadataBinding(proposal) {
2043
2089
  const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
2044
2090
  const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
2045
- // The anchor arrives from an untrusted party via GUARDIAN, so check its
2046
- // block commitment against the one bound into the signed summary before
2047
- // anything executes against it. `ChainAnchor.deserialize` already enforced
2048
- // internal header/chain consistency.
2091
+ // The anchor arrives from an untrusted party via GUARDIAN, so check that it
2092
+ // names the block the signed summary binds: the rebuild below binds the
2093
+ // block it names. Nothing executes against it; the rebuild runs at the
2094
+ // chain tip. `ChainAnchor.deserialize` already enforced internal
2095
+ // header/chain consistency.
2049
2096
  const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
2050
2097
  try {
2051
2098
  const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
@@ -2072,6 +2119,12 @@ export class Multisig {
2072
2119
  // binding checks for this type.
2073
2120
  return txSummaryCommitment;
2074
2121
  }
2122
+ // The rebuild reads the chain's fee faucet from the synced protocol
2123
+ // configuration and executes at the tip, so the store has to have synced
2124
+ // to the block the summary binds; a cosigner that has only just loaded
2125
+ // the account has not.
2126
+ await this.syncToBoundBlock(binding.boundBlockNum);
2127
+ const webClient = await this.getRawClient();
2075
2128
  // A consume-notes summary commits to *authenticated* consumption (see
2076
2129
  // ensureNotesAuthenticated), which miden-client decides from this store
2077
2130
  // alone. Put the store in that mode before the rebuild, or a cosigner
@@ -2081,8 +2134,7 @@ export class Multisig {
2081
2134
  await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
2082
2135
  }
2083
2136
  const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
2084
- const webClient = await this.getRawClient();
2085
- const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
2137
+ const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
2086
2138
  const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
2087
2139
  if (reconstructedCommitment !== txSummaryCommitment) {
2088
2140
  throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
@@ -2093,12 +2145,6 @@ export class Multisig {
2093
2145
  anchor.free();
2094
2146
  }
2095
2147
  }
2096
- /**
2097
- * Decodes a proposal's chain anchor. Throws when absent: a proposal without
2098
- * an anchor was created at an unknown reference block, so its signed summary
2099
- * cannot be reproduced, verified, or executed. The caller owns the returned
2100
- * anchor and must `free()` it once done.
2101
- */
2102
2148
  /**
2103
2149
  * Reads a proposal's salt. Throws when absent, because there is nothing to fall
2104
2150
  * back to.
@@ -2140,11 +2186,16 @@ export class Multisig {
2140
2186
  }
2141
2187
  return saltHex;
2142
2188
  }
2189
+ /**
2190
+ * Decodes a proposal's chain anchor, which names the block its signed summary
2191
+ * binds. Throws when absent: every proposal carries one, so a proposal
2192
+ * without it is malformed and is neither verified nor executed. The caller
2193
+ * owns the returned anchor and must `free()` it once done.
2194
+ */
2143
2195
  requireProposalAnchor(proposalId, metadata) {
2144
2196
  if (!metadata.chainAnchor) {
2145
- throw new Error(`Proposal ${proposalId} has no chain anchor; it was created without ` +
2146
- 'chain-anchored execution and its signed summary cannot be reproduced ' +
2147
- 'at the original reference block');
2197
+ throw new Error(`Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
2198
+ 'summary binds; it cannot be verified or executed');
2148
2199
  }
2149
2200
  return chainAnchorFromBase64(metadata.chainAnchor);
2150
2201
  }