@openzeppelin/miden-multisig-client 0.18.0 → 0.18.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/README.md +176 -23
- package/dist/client.d.ts +41 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +79 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts.map +1 -1
- package/dist/lookupAuth.js +2 -20
- package/dist/lookupAuth.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +27 -1
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +33 -0
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig.d.ts +77 -11
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +247 -181
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +2 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/recovery/transportDrain.d.ts +38 -30
- package/dist/recovery/transportDrain.d.ts.map +1 -1
- package/dist/recovery/transportDrain.js +113 -113
- package/dist/recovery/transportDrain.js.map +1 -1
- package/dist/session/grant.d.ts +40 -0
- package/dist/session/grant.d.ts.map +1 -0
- package/dist/session/grant.js +125 -0
- package/dist/session/grant.js.map +1 -0
- package/dist/session/key.d.ts +103 -0
- package/dist/session/key.d.ts.map +1 -0
- package/dist/session/key.js +160 -0
- package/dist/session/key.js.map +1 -0
- package/dist/session/manager.d.ts +76 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -1
- package/dist/signers/ecdsa.d.ts.map +1 -1
- package/dist/signers/ecdsa.js +9 -0
- package/dist/signers/ecdsa.js.map +1 -1
- package/dist/signers/falcon.d.ts +5 -1
- package/dist/signers/falcon.d.ts.map +1 -1
- package/dist/signers/falcon.js +9 -0
- package/dist/signers/falcon.js.map +1 -1
- package/dist/signers/ledger.d.ts +9 -1
- package/dist/signers/ledger.d.ts.map +1 -1
- package/dist/signers/ledger.js +13 -1
- package/dist/signers/ledger.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +13 -1
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +25 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/para.d.ts +5 -1
- package/dist/signers/para.d.ts.map +1 -1
- package/dist/signers/para.js +9 -0
- package/dist/signers/para.js.map +1 -1
- package/dist/transaction/authArgs.d.ts +2 -2
- package/dist/transaction/authArgs.js +2 -2
- package/dist/transaction/options.d.ts +2 -2
- package/dist/transaction/summary.d.ts +17 -61
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +49 -95
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +11 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +26 -69
- package/dist/utils/eip712.d.ts.map +1 -1
- package/dist/utils/eip712.js +101 -8
- package/dist/utils/eip712.js.map +1 -1
- package/dist/utils/felt.d.ts +7 -1
- package/dist/utils/felt.d.ts.map +1 -1
- package/dist/utils/felt.js +18 -1
- package/dist/utils/felt.js.map +1 -1
- package/package.json +4 -4
- package/src/client.ts +97 -2
- package/src/index.ts +29 -8
- package/src/lookupAuth.ts +3 -22
- package/src/multisig/authArgErrors.ts +42 -1
- package/src/multisig.ts +318 -207
- package/src/proposal/metadata.ts +2 -0
- package/src/recovery/transportDrain.ts +118 -124
- package/src/session/grant.ts +143 -0
- package/src/session/key.ts +215 -0
- package/src/session/manager.ts +273 -0
- package/src/signers/ecdsa.ts +12 -1
- package/src/signers/falcon.ts +12 -1
- package/src/signers/ledger.ts +19 -2
- package/src/signers/miden-wallet.ts +29 -1
- package/src/signers/para.ts +12 -1
- package/src/transaction/authArgs.ts +2 -2
- package/src/transaction/options.ts +2 -2
- package/src/transaction/summary.ts +62 -110
- package/src/transaction.ts +1 -6
- package/src/types/proposal.ts +11 -5
- package/src/utils/eip712.ts +130 -10
- package/src/utils/felt.ts +19 -1
package/dist/multisig.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* This class wraps a Miden SDK Account and provides GUARDIAN integration
|
|
5
5
|
* for proposal management.
|
|
6
6
|
*/
|
|
7
|
-
import { GuardianHttpClient } from '@openzeppelin/guardian-client';
|
|
8
|
-
import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
|
|
7
|
+
import { GuardianHttpClient, GuardianHttpError } from '@openzeppelin/guardian-client';
|
|
8
|
+
import { BoundBlockMismatchError, 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 {
|
|
10
|
+
import { executeForSummaryAtTip, isStaleChainError, prepareTipExecution, syncToBoundBlock, summaryApprovalExpirationBlockNum, summaryBoundBlockNum, 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';
|
|
@@ -63,21 +63,18 @@ function deserializeTransactionRequest(bytes) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* fail loudly instead.
|
|
66
|
+
* Runtime guard for pre-#387 positional callers. Untyped JS passing the old
|
|
67
|
+
* `nonce` number (or a legacy trailing argument) would otherwise bind it as
|
|
68
|
+
* the options bag and silently fall back to every default — a public note
|
|
69
|
+
* instead of a private one, or the current threshold instead of the
|
|
70
|
+
* requested one — so it must fail loudly instead.
|
|
72
71
|
*/
|
|
73
|
-
function
|
|
72
|
+
function assertProposalOptionsBag(method, options, legacyArgs = []) {
|
|
74
73
|
if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
|
|
75
74
|
throw new Error(`${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`);
|
|
76
75
|
}
|
|
77
|
-
return options.nonce ?? Date.now();
|
|
78
76
|
}
|
|
79
|
-
function proposalRequestBinding(summary,
|
|
80
|
-
const boundBlockNum = anchor.blockNum();
|
|
77
|
+
function proposalRequestBinding(summary, boundBlockNum, saltHex) {
|
|
81
78
|
return {
|
|
82
79
|
saltHex: normalizeHexWord(saltHex),
|
|
83
80
|
boundBlockNum,
|
|
@@ -125,6 +122,8 @@ const PRE_SWITCH_IMPORT_TIMED_OUT = Symbol('pre-switch proposal-note import time
|
|
|
125
122
|
const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
|
|
126
123
|
/** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
|
|
127
124
|
const MAX_SALT_HEX_DIGITS = 64;
|
|
125
|
+
/** Block numbers are `u32` on chain. */
|
|
126
|
+
const MAX_BLOCK_NUM = 0xffff_ffff;
|
|
128
127
|
/**
|
|
129
128
|
* Consecutive successful listings that must omit a guardian-known proposal
|
|
130
129
|
* not yet listed (a fresh create during read-your-writes lag, or one
|
|
@@ -223,6 +222,55 @@ export class Multisig {
|
|
|
223
222
|
readStoreAccount() {
|
|
224
223
|
return retryRpcRead(() => this.midenClient.accounts.get(this._accountId), this.rpcConfig);
|
|
225
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* The nonce a proposal is labelled with: `options.nonce`, or the store
|
|
227
|
+
* account's nonce plus one, the nonce the executed transaction will
|
|
228
|
+
* have. That is what the Rust SDK labels with, what the stale-proposal
|
|
229
|
+
* filters compare with the account nonce, and what GUARDIAN's candidate
|
|
230
|
+
* queue requires of a proposal built on its tail (issue #17). Through
|
|
231
|
+
* 0.18.0 the default was `Date.now()`, a key no admission rule
|
|
232
|
+
* could relate to the account's state. The label is read after any sync
|
|
233
|
+
* the method performs (note authentication, the offline switch's node
|
|
234
|
+
* sync), on the account the summary is then built from; a caller that
|
|
235
|
+
* has just read that account passes it to save the store read.
|
|
236
|
+
*/
|
|
237
|
+
async resolveProposalNonce(method, options, legacyArgs = [], account) {
|
|
238
|
+
assertProposalOptionsBag(method, options, legacyArgs);
|
|
239
|
+
if (options.nonce !== undefined && options.nonce !== null) {
|
|
240
|
+
return options.nonce;
|
|
241
|
+
}
|
|
242
|
+
const labelled = account ?? (await this.getStoreAccount());
|
|
243
|
+
const next = labelled.nonce().asInt() + 1n;
|
|
244
|
+
if (next > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
245
|
+
throw new Error(`${method}: the account nonce ${next - 1n} is too large to label a proposal with as a number; pass options.nonce`);
|
|
246
|
+
}
|
|
247
|
+
return Number(next);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Refuses to execute a proposal GUARDIAN pinned to a state other than the
|
|
251
|
+
* one this client holds. The transaction executes on this client's own
|
|
252
|
+
* record of the account while the execution push names the pinned base,
|
|
253
|
+
* so the two have to be the same state. A proposal pinned to a state the
|
|
254
|
+
* account has since left was made for that state, and its transaction
|
|
255
|
+
* would otherwise run again on the current one. One pinned to a state this
|
|
256
|
+
* client does not hold (a server that queues chained candidates pins a
|
|
257
|
+
* proposal to the newest queued candidate's post-state) would be admitted
|
|
258
|
+
* behind that candidate while the transaction itself lands on the older
|
|
259
|
+
* state, leaving GUARDIAN behind the chain.
|
|
260
|
+
*/
|
|
261
|
+
async assertExecutesOnPinnedBase(proposalId, pinnedBase) {
|
|
262
|
+
const local = await this.localAccountCommitment();
|
|
263
|
+
const pinned = normalizeHexWord(pinnedBase);
|
|
264
|
+
if (local !== pinned) {
|
|
265
|
+
throw new Error(`Proposal ${proposalId} was made for account state ${pinned}, but this client's account is at ${local}: ` +
|
|
266
|
+
'sync the account (syncState) and retry, or create a new proposal if the account has moved past that state');
|
|
267
|
+
}
|
|
268
|
+
return local;
|
|
269
|
+
}
|
|
270
|
+
/** The commitment of this client's record of the account. */
|
|
271
|
+
async localAccountCommitment() {
|
|
272
|
+
return normalizeHexWord((await this.getStoreAccount()).to_commitment().toHex());
|
|
273
|
+
}
|
|
226
274
|
/**
|
|
227
275
|
* Read the current ordered signer public-key commitments from account
|
|
228
276
|
* storage (store-backed state, falling back to the snapshot).
|
|
@@ -319,6 +367,17 @@ export class Multisig {
|
|
|
319
367
|
signatureScheme: this.signer.scheme,
|
|
320
368
|
};
|
|
321
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Derives a new proposal's summary at the chain tip, with the block it binds
|
|
372
|
+
* for the proposal's `boundBlockNum`.
|
|
373
|
+
*/
|
|
374
|
+
async deriveProposalSummary(request) {
|
|
375
|
+
const summary = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
|
|
376
|
+
return {
|
|
377
|
+
summaryBase64: uint8ArrayToBase64(summary.serialize()),
|
|
378
|
+
boundBlockNum: summaryBoundBlockNum(summary),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
322
381
|
warnOnOverrideDilution(newNumSigners) {
|
|
323
382
|
const current = this.signerCommitments.length;
|
|
324
383
|
for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
|
|
@@ -582,10 +641,11 @@ export class Multisig {
|
|
|
582
641
|
* without applying its listing.
|
|
583
642
|
*
|
|
584
643
|
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
585
|
-
* `filterVisibleProposals`)
|
|
586
|
-
*
|
|
587
|
-
* nonce
|
|
588
|
-
*
|
|
644
|
+
* `filterVisibleProposals`). This SDK labels a proposal with the account's
|
|
645
|
+
* next nonce by default, as the Rust SDK does, but a caller may label with
|
|
646
|
+
* its own `nonce` (and proposals made through 0.18.0 carry a
|
|
647
|
+
* timestamp), so the Rust client's `proposal.nonce <= account.nonce()`
|
|
648
|
+
* filter is not applied here. This is an intentional TS/Rust surface
|
|
589
649
|
* difference.
|
|
590
650
|
*/
|
|
591
651
|
syncProposals() {
|
|
@@ -617,8 +677,10 @@ export class Multisig {
|
|
|
617
677
|
const existingProposal = this.proposals.get(proposalId);
|
|
618
678
|
const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
|
|
619
679
|
// The outcome lands on the proposal either way; a failure is reported
|
|
620
|
-
// there rather than failing the sync.
|
|
621
|
-
|
|
680
|
+
// there rather than failing the sync. The chain was synced once above,
|
|
681
|
+
// so a bound block past that height stays a retryable failure until the
|
|
682
|
+
// next listing instead of costing a sync per proposal.
|
|
683
|
+
await this.verifyProposalMetadataBinding(proposal, async () => undefined).catch(() => undefined);
|
|
622
684
|
reported.set(proposal.id, { delta, verified: proposal });
|
|
623
685
|
}
|
|
624
686
|
if (generation !== this.syncGeneration) {
|
|
@@ -690,6 +752,10 @@ export class Multisig {
|
|
|
690
752
|
catch {
|
|
691
753
|
currentNonce = undefined;
|
|
692
754
|
}
|
|
755
|
+
// At most one chain sync for the whole listing, however many proposals
|
|
756
|
+
// bind a block above the store's sync height.
|
|
757
|
+
let listingSync;
|
|
758
|
+
const syncOnce = () => (listingSync ??= this.syncChain());
|
|
693
759
|
const proposals = [];
|
|
694
760
|
const skipped = [];
|
|
695
761
|
for (let position = 0; position < deltas.length; position += 1) {
|
|
@@ -706,7 +772,7 @@ export class Multisig {
|
|
|
706
772
|
if (currentNonce !== undefined && BigInt(proposal.nonce) <= currentNonce) {
|
|
707
773
|
continue;
|
|
708
774
|
}
|
|
709
|
-
await this.verifyProposalMetadataBinding(proposal);
|
|
775
|
+
await this.verifyProposalMetadataBinding(proposal, syncOnce);
|
|
710
776
|
proposals.push(proposal);
|
|
711
777
|
}
|
|
712
778
|
catch (error) {
|
|
@@ -759,7 +825,7 @@ export class Multisig {
|
|
|
759
825
|
* current threshold)
|
|
760
826
|
*/
|
|
761
827
|
async createAddSignerProposal(newCommitment, options = {}, ...legacyArgs) {
|
|
762
|
-
|
|
828
|
+
assertProposalOptionsBag('createAddSignerProposal', options, legacyArgs);
|
|
763
829
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
764
830
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
765
831
|
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
@@ -772,12 +838,10 @@ export class Multisig {
|
|
|
772
838
|
});
|
|
773
839
|
this.warnOnOverrideDilution(targetSignerCommitments.length);
|
|
774
840
|
const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
775
|
-
const
|
|
776
|
-
const
|
|
777
|
-
anchor.free();
|
|
778
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
841
|
+
const proposalNonce = await this.resolveProposalNonce('createAddSignerProposal', options);
|
|
842
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
779
843
|
const metadata = {
|
|
780
|
-
|
|
844
|
+
boundBlockNum,
|
|
781
845
|
proposalType: 'add_signer',
|
|
782
846
|
targetThreshold,
|
|
783
847
|
targetSignerCommitments,
|
|
@@ -795,7 +859,7 @@ export class Multisig {
|
|
|
795
859
|
* min of current threshold and new signer count)
|
|
796
860
|
*/
|
|
797
861
|
async createRemoveSignerProposal(signerToRemove, options = {}, ...legacyArgs) {
|
|
798
|
-
|
|
862
|
+
assertProposalOptionsBag('createRemoveSignerProposal', options, legacyArgs);
|
|
799
863
|
const normalizedRemove = signerToRemove.toLowerCase();
|
|
800
864
|
const targetSignerCommitments = this.signerCommitments.filter((c) => c.toLowerCase() !== normalizedRemove);
|
|
801
865
|
if (targetSignerCommitments.length === this.signerCommitments.length) {
|
|
@@ -809,12 +873,10 @@ export class Multisig {
|
|
|
809
873
|
throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
|
|
810
874
|
}
|
|
811
875
|
const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
anchor.free();
|
|
815
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
876
|
+
const proposalNonce = await this.resolveProposalNonce('createRemoveSignerProposal', options);
|
|
877
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
816
878
|
const metadata = {
|
|
817
|
-
|
|
879
|
+
boundBlockNum,
|
|
818
880
|
proposalType: 'remove_signer',
|
|
819
881
|
targetThreshold,
|
|
820
882
|
targetSignerCommitments,
|
|
@@ -831,7 +893,7 @@ export class Multisig {
|
|
|
831
893
|
* @param options - Optional settings: `nonce`
|
|
832
894
|
*/
|
|
833
895
|
async createChangeThresholdProposal(newThreshold, options = {}) {
|
|
834
|
-
|
|
896
|
+
assertProposalOptionsBag('createChangeThresholdProposal', options);
|
|
835
897
|
if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
|
|
836
898
|
throw new Error(`Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`);
|
|
837
899
|
}
|
|
@@ -839,12 +901,10 @@ export class Multisig {
|
|
|
839
901
|
throw new Error('New threshold is the same as current threshold');
|
|
840
902
|
}
|
|
841
903
|
const { request, salt } = await buildUpdateSignersTransactionRequest(this.midenClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
|
|
842
|
-
const
|
|
843
|
-
const
|
|
844
|
-
anchor.free();
|
|
845
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
904
|
+
const proposalNonce = await this.resolveProposalNonce('createChangeThresholdProposal', options);
|
|
905
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
846
906
|
const metadata = {
|
|
847
|
-
|
|
907
|
+
boundBlockNum,
|
|
848
908
|
proposalType: 'change_threshold',
|
|
849
909
|
targetThreshold: newThreshold,
|
|
850
910
|
targetSignerCommitments: this.signerCommitments,
|
|
@@ -855,7 +915,7 @@ export class Multisig {
|
|
|
855
915
|
return this.createProposal(proposalNonce, summaryBase64, metadata);
|
|
856
916
|
}
|
|
857
917
|
async createUpdateProcedureThresholdProposal(targetProcedure, targetThreshold, options = {}) {
|
|
858
|
-
|
|
918
|
+
assertProposalOptionsBag('createUpdateProcedureThresholdProposal', options);
|
|
859
919
|
if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
|
|
860
920
|
throw new Error(`Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`);
|
|
861
921
|
}
|
|
@@ -867,15 +927,13 @@ export class Multisig {
|
|
|
867
927
|
throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
|
|
868
928
|
}
|
|
869
929
|
const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(this.midenClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
|
|
870
|
-
const
|
|
871
|
-
const
|
|
872
|
-
anchor.free();
|
|
873
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
930
|
+
const proposalNonce = await this.resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
|
|
931
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
874
932
|
const action = targetThreshold === 0
|
|
875
933
|
? `Clear threshold override for ${targetProcedure}`
|
|
876
934
|
: `Set ${targetProcedure} threshold override to ${targetThreshold}`;
|
|
877
935
|
const metadata = {
|
|
878
|
-
|
|
936
|
+
boundBlockNum,
|
|
879
937
|
proposalType: 'update_procedure_threshold',
|
|
880
938
|
targetProcedure,
|
|
881
939
|
targetThreshold,
|
|
@@ -893,11 +951,12 @@ export class Multisig {
|
|
|
893
951
|
* @param options - Optional settings: `nonce`
|
|
894
952
|
*/
|
|
895
953
|
async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
|
|
896
|
-
|
|
954
|
+
assertProposalOptionsBag('createSwitchGuardianProposal', options);
|
|
897
955
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
898
956
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
899
957
|
// sign/execute (which fetch from GUARDIAN) can find it. To leave an
|
|
900
958
|
// unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
|
|
959
|
+
const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposal', options);
|
|
901
960
|
return this.createProposal(proposalNonce, summaryBase64, metadata);
|
|
902
961
|
}
|
|
903
962
|
/**
|
|
@@ -920,12 +979,9 @@ export class Multisig {
|
|
|
920
979
|
approvalExpirationDelta,
|
|
921
980
|
signatureScheme: this.signer.scheme,
|
|
922
981
|
});
|
|
923
|
-
const {
|
|
924
|
-
const chainAnchor = chainAnchorToBase64(anchor);
|
|
925
|
-
anchor.free();
|
|
926
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
982
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
927
983
|
const metadata = {
|
|
928
|
-
|
|
984
|
+
boundBlockNum,
|
|
929
985
|
proposalType: 'switch_guardian',
|
|
930
986
|
saltHex: salt.toHex(),
|
|
931
987
|
requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
|
|
@@ -961,13 +1017,14 @@ export class Multisig {
|
|
|
961
1017
|
* @param options - Optional settings: `nonce`
|
|
962
1018
|
*/
|
|
963
1019
|
async createSwitchGuardianProposalOffline(newGuardianEndpoint, newGuardianPubkey, options = {}) {
|
|
964
|
-
|
|
1020
|
+
assertProposalOptionsBag('createSwitchGuardianProposalOffline', options);
|
|
965
1021
|
// Sync with the Miden node and refresh the cached account/config before
|
|
966
1022
|
// building (mirrors the Rust `sync_network_only`): with no GUARDIAN push
|
|
967
1023
|
// to reject a stale delta at creation, a summary built from stale local
|
|
968
1024
|
// state — or a readiness threshold read from stale config — would only
|
|
969
1025
|
// fail at execution, after the whole side-channel cosigning ceremony.
|
|
970
|
-
await this.syncNetworkOnly();
|
|
1026
|
+
const synced = await this.syncNetworkOnly();
|
|
1027
|
+
const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposalOffline', options, [], synced);
|
|
971
1028
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
972
1029
|
const exported = {
|
|
973
1030
|
accountId: this._accountId,
|
|
@@ -991,7 +1048,7 @@ export class Multisig {
|
|
|
991
1048
|
* @param options - Optional settings: `nonce`
|
|
992
1049
|
*/
|
|
993
1050
|
async createConsumeNotesProposal(noteIds, options = {}) {
|
|
994
|
-
|
|
1051
|
+
assertProposalOptionsBag('createConsumeNotesProposal', options);
|
|
995
1052
|
if (noteIds.length === 0) {
|
|
996
1053
|
throw new Error('At least one note ID is required');
|
|
997
1054
|
}
|
|
@@ -1006,16 +1063,14 @@ export class Multisig {
|
|
|
1006
1063
|
}
|
|
1007
1064
|
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
1008
1065
|
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
1009
|
-
// the notes are authenticated here first, before the
|
|
1066
|
+
// the notes are authenticated here first, before the summary is derived.
|
|
1010
1067
|
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
1068
|
+
const proposalNonce = await this.resolveProposalNonce('createConsumeNotesProposal', options);
|
|
1011
1069
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
1012
1070
|
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(this.midenClient, fetchedNotes, this.proposalRequestOptions(options));
|
|
1013
|
-
const {
|
|
1014
|
-
const chainAnchor = chainAnchorToBase64(anchor);
|
|
1015
|
-
anchor.free();
|
|
1016
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1071
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1017
1072
|
const metadata = {
|
|
1018
|
-
|
|
1073
|
+
boundBlockNum,
|
|
1019
1074
|
proposalType: 'consume_notes',
|
|
1020
1075
|
noteIds,
|
|
1021
1076
|
metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
|
|
@@ -1046,7 +1101,7 @@ export class Multisig {
|
|
|
1046
1101
|
* `reclaimHeight`/`timelockHeight` build a P2IDE note (issue #366)
|
|
1047
1102
|
*/
|
|
1048
1103
|
async createP2idProposal(recipientId, faucetId, amount, options = {}, ...legacyArgs) {
|
|
1049
|
-
|
|
1104
|
+
assertProposalOptionsBag('createP2idProposal', options, legacyArgs);
|
|
1050
1105
|
if (amount <= 0n) {
|
|
1051
1106
|
throw new Error('Amount must be greater than 0');
|
|
1052
1107
|
}
|
|
@@ -1054,12 +1109,10 @@ export class Multisig {
|
|
|
1054
1109
|
// CreateP2idProposalOptions can't be silently dropped before the builder.
|
|
1055
1110
|
const { nonce: _nonce, ...noteOptions } = options;
|
|
1056
1111
|
const { request, salt } = await buildP2idTransactionRequest(this.midenClient, this._accountId, recipientId, faucetId, amount, noteOptions);
|
|
1057
|
-
const
|
|
1058
|
-
const
|
|
1059
|
-
anchor.free();
|
|
1060
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1112
|
+
const proposalNonce = await this.resolveProposalNonce('createP2idProposal', options);
|
|
1113
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1061
1114
|
const metadata = {
|
|
1062
|
-
|
|
1115
|
+
boundBlockNum,
|
|
1063
1116
|
proposalType: 'p2id',
|
|
1064
1117
|
saltHex: salt.toHex(),
|
|
1065
1118
|
requiredSignatures: this.getEffectiveThreshold('p2id'),
|
|
@@ -1514,7 +1567,7 @@ export class Multisig {
|
|
|
1514
1567
|
* @param proposalId - The proposal commitment/ID
|
|
1515
1568
|
*/
|
|
1516
1569
|
async executeProposal(proposalId) {
|
|
1517
|
-
const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
|
|
1570
|
+
const { metadata, finalRequest, proposal, executedBase } = await this.prepareProposalExecution(proposalId);
|
|
1518
1571
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1519
1572
|
// #417: import notes embedded in pending proposals from the old
|
|
1520
1573
|
// GUARDIAN. Must run before the switch executes and repoints;
|
|
@@ -1531,13 +1584,16 @@ export class Multisig {
|
|
|
1531
1584
|
// Canonicalize the executed delta on the pre-switch GUARDIAN (clears the
|
|
1532
1585
|
// pending proposal). Must run before `this.guardian` is repointed below.
|
|
1533
1586
|
// Best-effort: an unreachable old GUARDIAN must not block the switch, so
|
|
1534
|
-
// errors are swallowed (mirrors the Rust execute path
|
|
1587
|
+
// errors are swallowed (mirrors the Rust execute path, which names the
|
|
1588
|
+
// state it executed on and sends the summary it holds; GUARDIAN assigns
|
|
1589
|
+
// the status).
|
|
1535
1590
|
try {
|
|
1536
|
-
const normalizedProposalId = normalizeHexWord(proposal.id);
|
|
1537
|
-
const switchDelta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1538
1591
|
await this.guardian.pushDelta({
|
|
1539
|
-
|
|
1540
|
-
|
|
1592
|
+
accountId: this._accountId,
|
|
1593
|
+
nonce: proposal.nonce,
|
|
1594
|
+
prevCommitment: executedBase,
|
|
1595
|
+
deltaPayload: { data: proposal.txSummary },
|
|
1596
|
+
status: { status: 'candidate', timestamp: new Date().toISOString() },
|
|
1541
1597
|
});
|
|
1542
1598
|
}
|
|
1543
1599
|
catch (error) {
|
|
@@ -1577,19 +1633,9 @@ export class Multisig {
|
|
|
1577
1633
|
await this.syncChain();
|
|
1578
1634
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1579
1635
|
const existing = this.getLocalProposal(proposalId);
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
1584
|
-
const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
|
|
1585
|
-
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
1586
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
1587
|
-
throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
finally {
|
|
1591
|
-
anchor.free();
|
|
1592
|
-
}
|
|
1636
|
+
// Parsed for its id check: a proposal GUARDIAN does not serve, or whose id
|
|
1637
|
+
// is not its summary's commitment, is not submitted (as in the Rust SDK).
|
|
1638
|
+
this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
|
|
1593
1639
|
await this.submitAtTip(request);
|
|
1594
1640
|
}
|
|
1595
1641
|
/**
|
|
@@ -1608,9 +1654,12 @@ export class Multisig {
|
|
|
1608
1654
|
async syncChain() {
|
|
1609
1655
|
await retryRpcRead(() => this.midenClient.syncChain(), this.rpcConfig);
|
|
1610
1656
|
}
|
|
1611
|
-
/**
|
|
1612
|
-
|
|
1613
|
-
|
|
1657
|
+
/**
|
|
1658
|
+
* {@link syncToBoundBlock} with this client's RPC retry policy. A listing
|
|
1659
|
+
* passes its own `syncChain` so it syncs at most once for all its proposals.
|
|
1660
|
+
*/
|
|
1661
|
+
async syncToBoundBlock(boundBlockNum, syncChain = () => this.syncChain()) {
|
|
1662
|
+
await syncToBoundBlock(this.midenClient, boundBlockNum, syncChain);
|
|
1614
1663
|
}
|
|
1615
1664
|
/**
|
|
1616
1665
|
* Create a proposal from a producer-built transaction the SDK does not model.
|
|
@@ -1620,7 +1669,7 @@ export class Multisig {
|
|
|
1620
1669
|
* `prepareCustomExecution`.
|
|
1621
1670
|
*/
|
|
1622
1671
|
async createCustomProposal(transactionRequestBytes, proposalType, options = {}) {
|
|
1623
|
-
|
|
1672
|
+
assertProposalOptionsBag('createCustomProposal', options);
|
|
1624
1673
|
const label = proposalType.trim().toLowerCase();
|
|
1625
1674
|
if (label.length === 0) {
|
|
1626
1675
|
throw new Error('proposalType must not be empty');
|
|
@@ -1632,12 +1681,10 @@ export class Multisig {
|
|
|
1632
1681
|
throw new Error(`'${label}' is a built-in proposal type; use the typed proposal API instead`);
|
|
1633
1682
|
}
|
|
1634
1683
|
const request = deserializeTransactionRequest(transactionRequestBytes);
|
|
1635
|
-
const
|
|
1636
|
-
const
|
|
1637
|
-
anchor.free();
|
|
1638
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1684
|
+
const proposalNonce = await this.resolveProposalNonce('createCustomProposal', options);
|
|
1685
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1639
1686
|
const metadata = {
|
|
1640
|
-
|
|
1687
|
+
boundBlockNum,
|
|
1641
1688
|
proposalType: 'custom',
|
|
1642
1689
|
description: '',
|
|
1643
1690
|
rawProposalType: label,
|
|
@@ -1670,24 +1717,11 @@ export class Multisig {
|
|
|
1670
1717
|
if (signaturesForExecution.length < effectiveThreshold) {
|
|
1671
1718
|
throw new Error(`Proposal is not ready for execution: have ${signaturesForExecution.length} of ${effectiveThreshold} required signatures.`);
|
|
1672
1719
|
}
|
|
1720
|
+
await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment);
|
|
1673
1721
|
const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
|
|
1722
|
+
this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, txSummary);
|
|
1674
1723
|
const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
|
|
1675
1724
|
const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
|
|
1676
|
-
// The anchor arrives from an untrusted party via GUARDIAN, so its block
|
|
1677
|
-
// commitment is checked against the signed summary: it has to name the
|
|
1678
|
-
// block the summary binds. The probe itself runs at the chain tip, where
|
|
1679
|
-
// the request's declared bound block reproduces the signed summary.
|
|
1680
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1681
|
-
try {
|
|
1682
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
1683
|
-
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
1684
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
1685
|
-
throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
finally {
|
|
1689
|
-
anchor.free();
|
|
1690
|
-
}
|
|
1691
1725
|
const derived = await executeForSummaryAtTip(this.midenClient, this._accountId, bindingRequest);
|
|
1692
1726
|
const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
1693
1727
|
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
@@ -1809,11 +1843,15 @@ export class Multisig {
|
|
|
1809
1843
|
let delta;
|
|
1810
1844
|
if (isSwitchGuardian) {
|
|
1811
1845
|
txSummaryBase64 = proposal.txSummary;
|
|
1846
|
+
delta = await this.servedSwitchProposal(normalizedProposalId);
|
|
1812
1847
|
}
|
|
1813
1848
|
else {
|
|
1814
1849
|
delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1815
1850
|
txSummaryBase64 = delta.deltaPayload.txSummary.data;
|
|
1816
1851
|
}
|
|
1852
|
+
const executedBase = delta
|
|
1853
|
+
? await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment)
|
|
1854
|
+
: await this.localAccountCommitment();
|
|
1817
1855
|
const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
|
|
1818
1856
|
const txSummary = TransactionSummary.deserialize(txSummaryBytes);
|
|
1819
1857
|
const saltHex = this.requireProposalSaltHex(proposalId, metadata);
|
|
@@ -1898,19 +1936,37 @@ export class Multisig {
|
|
|
1898
1936
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1899
1937
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
1900
1938
|
}
|
|
1901
|
-
const
|
|
1902
|
-
let binding;
|
|
1903
|
-
try {
|
|
1904
|
-
binding = proposalRequestBinding(txSummary, anchor, saltHex);
|
|
1905
|
-
}
|
|
1906
|
-
finally {
|
|
1907
|
-
anchor.free();
|
|
1908
|
-
}
|
|
1939
|
+
const binding = proposalRequestBinding(txSummary, this.requireProposalBoundBlockNum(proposalId, metadata, txSummary), saltHex);
|
|
1909
1940
|
// A switch_guardian proposal is verified without a rebuild, so this may be
|
|
1910
1941
|
// the first time this client needs the chain at the bound block.
|
|
1911
1942
|
await this.syncToBoundBlock(binding.boundBlockNum);
|
|
1912
1943
|
const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, binding, adviceMap);
|
|
1913
|
-
return { finalRequest, metadata, proposal };
|
|
1944
|
+
return { finalRequest, metadata, proposal, executedBase };
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* A switch_guardian proposal as the pre-switch GUARDIAN serves it, so the
|
|
1948
|
+
* state it pinned the proposal to is checked as for every other type, or
|
|
1949
|
+
* `undefined` when that GUARDIAN does not serve it. Not holding the
|
|
1950
|
+
* proposal (one made offline) is the designed offline case. Any other
|
|
1951
|
+
* failure, whether the GUARDIAN is unreachable or answers with an error
|
|
1952
|
+
* (an authentication failure, a paused or released account, rate limiting,
|
|
1953
|
+
* a server fault), is warned about. In every one of those cases the switch
|
|
1954
|
+
* then executes from its cached summary without the check: rotating away
|
|
1955
|
+
* from a GUARDIAN that is down or refusing service is what the switch
|
|
1956
|
+
* exists for, so that GUARDIAN cannot be what blocks it.
|
|
1957
|
+
*/
|
|
1958
|
+
async servedSwitchProposal(normalizedProposalId) {
|
|
1959
|
+
try {
|
|
1960
|
+
return await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1961
|
+
}
|
|
1962
|
+
catch (error) {
|
|
1963
|
+
if (error instanceof GuardianHttpError && error.code === 'proposal_not_found') {
|
|
1964
|
+
return undefined;
|
|
1965
|
+
}
|
|
1966
|
+
console.warn('The pre-switch GUARDIAN did not serve the switch proposal; executing it without ' +
|
|
1967
|
+
'checking the state it was pinned to', error);
|
|
1968
|
+
return undefined;
|
|
1969
|
+
}
|
|
1914
1970
|
}
|
|
1915
1971
|
/**
|
|
1916
1972
|
* Export a proposal for offline signing
|
|
@@ -2062,9 +2118,9 @@ export class Multisig {
|
|
|
2062
2118
|
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2063
2119
|
* while `syncProposals` keeps going with the outcome recorded.
|
|
2064
2120
|
*/
|
|
2065
|
-
async verifyProposalMetadataBinding(proposal) {
|
|
2121
|
+
async verifyProposalMetadataBinding(proposal, syncChain) {
|
|
2066
2122
|
try {
|
|
2067
|
-
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2123
|
+
const commitment = await this.checkProposalMetadataBinding(proposal, syncChain);
|
|
2068
2124
|
proposal.verification = { status: 'verified' };
|
|
2069
2125
|
return commitment;
|
|
2070
2126
|
}
|
|
@@ -2077,64 +2133,48 @@ export class Multisig {
|
|
|
2077
2133
|
throw error;
|
|
2078
2134
|
}
|
|
2079
2135
|
}
|
|
2080
|
-
async checkProposalMetadataBinding(proposal) {
|
|
2136
|
+
async checkProposalMetadataBinding(proposal, syncChain) {
|
|
2081
2137
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
2082
2138
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
|
|
2089
|
-
try {
|
|
2090
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
2091
|
-
const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
|
|
2092
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
2093
|
-
throw new Error(`Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`);
|
|
2094
|
-
}
|
|
2095
|
-
if (proposal.metadata.proposalType === 'custom') {
|
|
2096
|
-
// Custom proposals have no per-type reconstruction recipe;
|
|
2097
|
-
// the id ↔ tx_summary commitment match above is the only available
|
|
2098
|
-
// integrity guarantee for an opaque proposal.
|
|
2099
|
-
return txSummaryCommitment;
|
|
2100
|
-
}
|
|
2101
|
-
// The salt check needs no re-execution, so it runs for every built-in
|
|
2102
|
-
// type, switch_guardian included (as in the Rust SDK): a mismatched salt
|
|
2103
|
-
// would otherwise collect signatures and only fail in the VM.
|
|
2104
|
-
const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
|
|
2105
|
-
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2106
|
-
throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
|
|
2107
|
-
}
|
|
2108
|
-
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
2109
|
-
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2110
|
-
// the salt above and the guardian endpoint commitment provide the
|
|
2111
|
-
// binding checks for this type.
|
|
2112
|
-
return txSummaryCommitment;
|
|
2113
|
-
}
|
|
2114
|
-
// The rebuild reads the chain's fee faucet from the synced protocol
|
|
2115
|
-
// configuration and executes at the tip, so the store has to have synced
|
|
2116
|
-
// to the block the summary binds; a cosigner that has only just loaded
|
|
2117
|
-
// the account has not.
|
|
2118
|
-
await this.syncToBoundBlock(binding.boundBlockNum);
|
|
2119
|
-
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2120
|
-
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2121
|
-
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2122
|
-
// that never held these notes reproduces a different commitment.
|
|
2123
|
-
if (proposal.metadata.proposalType === 'consume_notes' &&
|
|
2124
|
-
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
2125
|
-
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2126
|
-
}
|
|
2127
|
-
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2128
|
-
const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
|
|
2129
|
-
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
2130
|
-
if (reconstructedCommitment !== txSummaryCommitment) {
|
|
2131
|
-
throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
|
|
2132
|
-
}
|
|
2139
|
+
const boundBlockNum = this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, summary);
|
|
2140
|
+
if (proposal.metadata.proposalType === 'custom') {
|
|
2141
|
+
// Custom proposals have no per-type reconstruction recipe; the id and
|
|
2142
|
+
// bound-block checks above are the only available integrity guarantee
|
|
2143
|
+
// for an opaque proposal.
|
|
2133
2144
|
return txSummaryCommitment;
|
|
2134
2145
|
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2146
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2147
|
+
// type, switch_guardian included (as in the Rust SDK).
|
|
2148
|
+
const binding = proposalRequestBinding(summary, boundBlockNum, this.requireProposalSaltHex(proposal.id, proposal.metadata));
|
|
2149
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2150
|
+
throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
|
|
2151
|
+
}
|
|
2152
|
+
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
2153
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2154
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2155
|
+
// binding checks for this type.
|
|
2156
|
+
return txSummaryCommitment;
|
|
2157
|
+
}
|
|
2158
|
+
// The rebuild reads the chain's fee faucet from the synced protocol
|
|
2159
|
+
// configuration and executes at the tip, so the store has to have synced
|
|
2160
|
+
// to the block the summary binds; a cosigner that has only just loaded
|
|
2161
|
+
// the account has not.
|
|
2162
|
+
await this.syncToBoundBlock(binding.boundBlockNum, syncChain);
|
|
2163
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2164
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2165
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2166
|
+
// that never held these notes reproduces a different commitment.
|
|
2167
|
+
if (proposal.metadata.proposalType === 'consume_notes' &&
|
|
2168
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
2169
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2170
|
+
}
|
|
2171
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2172
|
+
const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
|
|
2173
|
+
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
2174
|
+
if (reconstructedCommitment !== txSummaryCommitment) {
|
|
2175
|
+
throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
|
|
2137
2176
|
}
|
|
2177
|
+
return txSummaryCommitment;
|
|
2138
2178
|
}
|
|
2139
2179
|
/**
|
|
2140
2180
|
* Reads a proposal's salt. Throws when absent, because there is nothing to fall
|
|
@@ -2178,17 +2218,43 @@ export class Multisig {
|
|
|
2178
2218
|
return saltHex;
|
|
2179
2219
|
}
|
|
2180
2220
|
/**
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2221
|
+
* The block a proposal's signed summary binds, read from the summary as the
|
|
2222
|
+
* Rust SDK does.
|
|
2223
|
+
*
|
|
2224
|
+
* GUARDIAN serves `boundBlockNum` unsigned and the response is cast, not
|
|
2225
|
+
* parsed. An absent value, from a proposal a 0.18.0 client made, is accepted;
|
|
2226
|
+
* a legacy `chainAnchor` is not read. A present value has to be a block
|
|
2227
|
+
* number equal to the summary's, for every proposal type, so a wrong one is
|
|
2228
|
+
* refused by name before anything executes or is signed.
|
|
2229
|
+
*
|
|
2230
|
+
* @throws BoundBlockMismatchError when the served value names another block.
|
|
2185
2231
|
*/
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2232
|
+
requireProposalBoundBlockNum(proposalId, metadata, summary) {
|
|
2233
|
+
const declared = this.servedBoundBlockNum(proposalId, metadata);
|
|
2234
|
+
const boundBlockNum = summaryBoundBlockNum(summary);
|
|
2235
|
+
if (declared !== undefined && declared !== boundBlockNum) {
|
|
2236
|
+
throw new BoundBlockMismatchError({
|
|
2237
|
+
proposalId,
|
|
2238
|
+
declaredBoundBlockNum: declared,
|
|
2239
|
+
boundBlockNum,
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
return boundBlockNum;
|
|
2243
|
+
}
|
|
2244
|
+
/** The served `boundBlockNum`, `undefined` when absent; throws when malformed. */
|
|
2245
|
+
servedBoundBlockNum(proposalId, metadata) {
|
|
2246
|
+
const boundBlockNum = metadata.boundBlockNum;
|
|
2247
|
+
if (boundBlockNum === undefined || boundBlockNum === null) {
|
|
2248
|
+
return undefined;
|
|
2249
|
+
}
|
|
2250
|
+
if (typeof boundBlockNum !== 'number' ||
|
|
2251
|
+
!Number.isInteger(boundBlockNum) ||
|
|
2252
|
+
boundBlockNum < 0 ||
|
|
2253
|
+
boundBlockNum > MAX_BLOCK_NUM) {
|
|
2254
|
+
throw new Error(`Proposal ${proposalId} has a malformed boundBlockNum '${String(boundBlockNum)}': ` +
|
|
2255
|
+
`expected an integer between 0 and ${MAX_BLOCK_NUM}`);
|
|
2190
2256
|
}
|
|
2191
|
-
return
|
|
2257
|
+
return boundBlockNum;
|
|
2192
2258
|
}
|
|
2193
2259
|
/**
|
|
2194
2260
|
* An expired approval aborts in the auth procedure only at execution.
|