@openzeppelin/miden-multisig-client 0.17.0 → 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/README.md +171 -46
- package/dist/account/builder.d.ts +4 -4
- package/dist/account/builder.d.ts.map +1 -1
- package/dist/account/builder.js +17 -7
- package/dist/account/builder.js.map +1 -1
- package/dist/account/layout.d.ts +5 -5
- package/dist/account/layout.d.ts.map +1 -1
- package/dist/account/layout.js +5 -5
- package/dist/account/layout.js.map +1 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +79 -6
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +28 -31
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +42 -46
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig/consumeNotesErrors.d.ts +13 -1
- package/dist/multisig/consumeNotesErrors.d.ts.map +1 -1
- package/dist/multisig/consumeNotesErrors.js +17 -0
- package/dist/multisig/consumeNotesErrors.js.map +1 -1
- package/dist/multisig/signing.d.ts +1 -1
- package/dist/multisig/signing.d.ts.map +1 -1
- package/dist/multisig/signing.js +8 -3
- package/dist/multisig/signing.js.map +1 -1
- package/dist/multisig.d.ts +145 -18
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +493 -162
- package/dist/multisig.js.map +1 -1
- package/dist/procedures.d.ts +7 -7
- package/dist/procedures.js +7 -7
- package/dist/proposal/factory.d.ts.map +1 -1
- package/dist/proposal/factory.js +7 -0
- package/dist/proposal/factory.js.map +1 -1
- package/dist/prover/workflow.d.ts +7 -3
- package/dist/prover/workflow.d.ts.map +1 -1
- package/dist/prover/workflow.js +7 -5
- package/dist/prover/workflow.js.map +1 -1
- package/dist/raw-client.d.ts +1 -0
- package/dist/raw-client.d.ts.map +1 -1
- package/dist/raw-client.js +10 -2
- package/dist/raw-client.js.map +1 -1
- package/dist/recovery/publicNoteBackfill.js +1 -1
- package/dist/recovery/publicNoteBackfill.js.map +1 -1
- package/dist/retry/classify.d.ts +3 -0
- package/dist/retry/classify.d.ts.map +1 -1
- package/dist/retry/classify.js +2 -2
- package/dist/retry/classify.js.map +1 -1
- package/dist/signer.d.ts +1 -0
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +1 -0
- package/dist/signer.js.map +1 -1
- package/dist/signers/index.d.ts +1 -0
- package/dist/signers/index.d.ts.map +1 -1
- package/dist/signers/index.js +1 -0
- package/dist/signers/index.js.map +1 -1
- package/dist/signers/ledger.d.ts +25 -0
- package/dist/signers/ledger.d.ts.map +1 -0
- package/dist/signers/ledger.js +96 -0
- package/dist/signers/ledger.js.map +1 -0
- package/dist/state/adopt.d.ts +45 -0
- package/dist/state/adopt.d.ts.map +1 -0
- package/dist/state/adopt.js +101 -0
- package/dist/state/adopt.js.map +1 -0
- package/dist/transaction/authArgs.d.ts +57 -0
- package/dist/transaction/authArgs.d.ts.map +1 -0
- package/dist/transaction/authArgs.js +108 -0
- package/dist/transaction/authArgs.js.map +1 -0
- package/dist/transaction/consumeNotes.d.ts +9 -5
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +8 -23
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/transaction/noteAuthentication.d.ts +39 -0
- package/dist/transaction/noteAuthentication.d.ts.map +1 -0
- package/dist/transaction/noteAuthentication.js +94 -0
- package/dist/transaction/noteAuthentication.js.map +1 -0
- package/dist/transaction/options.d.ts +25 -0
- package/dist/transaction/options.d.ts.map +1 -1
- package/dist/transaction/p2id.d.ts +3 -2
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +36 -27
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction/summary.d.ts +126 -22
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +164 -22
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction/updateGuardian.d.ts +3 -3
- package/dist/transaction/updateGuardian.d.ts.map +1 -1
- package/dist/transaction/updateGuardian.js +5 -16
- package/dist/transaction/updateGuardian.js.map +1 -1
- package/dist/transaction/updateProcedureThreshold.d.ts +3 -3
- package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
- package/dist/transaction/updateProcedureThreshold.js +6 -16
- package/dist/transaction/updateProcedureThreshold.js.map +1 -1
- package/dist/transaction/updateSigners.d.ts +3 -3
- package/dist/transaction/updateSigners.d.ts.map +1 -1
- package/dist/transaction/updateSigners.js +9 -16
- package/dist/transaction/updateSigners.js.map +1 -1
- package/dist/transaction.d.ts +3 -2
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +3 -2
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +37 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +8 -0
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +80 -0
- package/dist/utils/eip712.d.ts.map +1 -0
- package/dist/utils/eip712.js +49 -0
- package/dist/utils/eip712.js.map +1 -0
- package/dist/utils/signature.d.ts +4 -0
- package/dist/utils/signature.d.ts.map +1 -1
- package/dist/utils/signature.js +49 -1
- package/dist/utils/signature.js.map +1 -1
- package/package.json +11 -6
- package/src/account/builder.ts +18 -7
- package/src/account/layout.ts +5 -5
- package/src/client.ts +94 -6
- package/src/index.ts +24 -3
- package/src/multisig/authArgErrors.ts +47 -53
- package/src/multisig/consumeNotesErrors.ts +20 -1
- package/src/multisig/signing.ts +8 -2
- package/src/multisig.ts +614 -205
- package/src/procedures.ts +7 -7
- package/src/proposal/factory.ts +7 -0
- package/src/prover/workflow.ts +7 -10
- package/src/raw-client.ts +11 -7
- package/src/recovery/publicNoteBackfill.ts +1 -1
- package/src/retry/classify.ts +3 -3
- package/src/signer.ts +1 -0
- package/src/signers/index.ts +1 -0
- package/src/signers/ledger.ts +122 -0
- package/src/state/adopt.ts +132 -0
- package/src/transaction/authArgs.ts +142 -0
- package/src/transaction/consumeNotes.ts +23 -30
- package/src/transaction/noteAuthentication.ts +136 -0
- package/src/transaction/options.ts +27 -0
- package/src/transaction/p2id.ts +45 -34
- package/src/transaction/summary.ts +239 -30
- package/src/transaction/updateGuardian.ts +8 -22
- package/src/transaction/updateProcedureThreshold.ts +8 -20
- package/src/transaction/updateSigners.ts +11 -22
- package/src/transaction.ts +18 -1
- package/src/types/proposal.ts +36 -5
- package/src/utils/eip712.ts +57 -0
- package/src/utils/signature.ts +57 -0
- package/src/prover/test-node.d.ts +0 -6
package/dist/multisig.js
CHANGED
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { GuardianHttpClient } from '@openzeppelin/guardian-client';
|
|
8
8
|
import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
|
|
9
|
-
import { Account, AccountId, AdviceMap,
|
|
10
|
-
import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary,
|
|
9
|
+
import { Account, AccountId, AdviceMap, FeltArray, Note, NoteExportFormat, NoteFile, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
|
|
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
|
+
import { validateMultisigConfig } from './account/builder.js';
|
|
13
|
+
import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
|
|
12
14
|
import { CONSUME_NOTES_METADATA_VERSION_V2, MAX_CONSUME_NOTES_METADATA_BYTES, } from './types/proposal.js';
|
|
13
15
|
import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
|
|
14
16
|
import { ConsumeNotesMetadataOversizeError, LegacyConsumeNotesNoteMissingError, NoteBindingMismatchError, UnsupportedMetadataVersionError, } from './multisig/consumeNotesErrors.js';
|
|
15
17
|
import { noteFromBase64, noteToBase64 } from './utils/encoding.js';
|
|
16
18
|
import { base64ToUint8Array, uint8ArrayToBase64, normalizeHexWord, } from './utils/encoding.js';
|
|
17
|
-
import { assertEcdsaSignatureRecoverable, buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
|
|
19
|
+
import { assertEcdsaSignatureRecoverable, buildEip712SignatureAdviceEntry, buildSignatureAdviceEntry, normalizeSignerCommitment, signatureHexToBytes, tryComputeEcdsaCommitmentHex, } from './utils/signature.js';
|
|
18
20
|
import { computeCommitmentFromTxSummary, accountIdToHex } from './multisig/helpers.js';
|
|
19
21
|
import { buildGuardianSignatureFromSigner } from './multisig/signing.js';
|
|
20
22
|
import { AccountInspector, assertCompleteDetectedConfig } from './inspector.js';
|
|
@@ -29,7 +31,9 @@ import { getRawMidenClient, getTransactionProver, requireMidenRpcEndpoint, } fro
|
|
|
29
31
|
import { resolveProverConfig, } from './prover/config.js';
|
|
30
32
|
import { ProverWorkflow } from './prover/workflow.js';
|
|
31
33
|
import { resolveRpcConfig, } from './rpc/config.js';
|
|
34
|
+
import { isTransientRpcError } from './rpc/errors.js';
|
|
32
35
|
import { retryRpcRead } from './rpc/retry.js';
|
|
36
|
+
import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
|
|
33
37
|
/**
|
|
34
38
|
* Represents a multisig account with GUARDIAN integration.
|
|
35
39
|
*/
|
|
@@ -72,6 +76,39 @@ function resolveProposalNonce(method, options, legacyArgs = []) {
|
|
|
72
76
|
}
|
|
73
77
|
return options.nonce ?? Date.now();
|
|
74
78
|
}
|
|
79
|
+
function proposalRequestBinding(summary, anchor, saltHex) {
|
|
80
|
+
const boundBlockNum = anchor.blockNum();
|
|
81
|
+
return {
|
|
82
|
+
saltHex: normalizeHexWord(saltHex),
|
|
83
|
+
boundBlockNum,
|
|
84
|
+
approvalExpirationDelta: approvalExpirationDeltaOf(summaryApprovalExpirationBlockNum(summary), boundBlockNum),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The approval expiration delta a rebuild has to pass: the absolute expiration
|
|
89
|
+
* block the summary binds, relative to the bound block. `undefined` for an
|
|
90
|
+
* approval that never expires.
|
|
91
|
+
*/
|
|
92
|
+
function approvalExpirationDeltaOf(expirationBlockNum, boundBlockNum) {
|
|
93
|
+
if (expirationBlockNum === undefined) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const delta = expirationBlockNum - boundBlockNum;
|
|
97
|
+
if (delta < 1) {
|
|
98
|
+
throw new Error(`Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
|
|
99
|
+
`block ${boundBlockNum} its summary binds`);
|
|
100
|
+
}
|
|
101
|
+
return delta;
|
|
102
|
+
}
|
|
103
|
+
function summarySaltHex(summary) {
|
|
104
|
+
const salt = summarySalt(summary);
|
|
105
|
+
try {
|
|
106
|
+
return normalizeHexWord(salt.toHex());
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
salt.free?.();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
75
112
|
/**
|
|
76
113
|
* Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
|
|
77
114
|
* stack applies request deadlines, and a half-dead old GUARDIAN must not
|
|
@@ -88,6 +125,12 @@ const PRE_SWITCH_IMPORT_TIMED_OUT = Symbol('pre-switch proposal-note import time
|
|
|
88
125
|
const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
|
|
89
126
|
/** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
|
|
90
127
|
const MAX_SALT_HEX_DIGITS = 64;
|
|
128
|
+
/**
|
|
129
|
+
* Consecutive successful listings that must omit a guardian-known proposal
|
|
130
|
+
* not yet listed (a fresh create during read-your-writes lag, or one
|
|
131
|
+
* orphaned by a GUARDIAN repoint) before the sync prunes it.
|
|
132
|
+
*/
|
|
133
|
+
const UNREPORTED_LISTING_MISS_LIMIT = 2;
|
|
91
134
|
export class Multisig {
|
|
92
135
|
account;
|
|
93
136
|
threshold;
|
|
@@ -104,6 +147,24 @@ export class Multisig {
|
|
|
104
147
|
_accountId;
|
|
105
148
|
midenRpcEndpoint;
|
|
106
149
|
proposals = new Map();
|
|
150
|
+
/** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
|
|
151
|
+
lastReportedProposalIds = new Set();
|
|
152
|
+
/**
|
|
153
|
+
* Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
|
|
154
|
+
* acknowledged `signProposal` signatures, plus every listed id. Only these
|
|
155
|
+
* are subject to miss-based pruning; offline creations and imports GUARDIAN
|
|
156
|
+
* never received are exempt.
|
|
157
|
+
*/
|
|
158
|
+
guardianKnownProposalIds = new Set();
|
|
159
|
+
/**
|
|
160
|
+
* Consecutive successful listings that omitted a guardian-known proposal
|
|
161
|
+
* not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
|
|
162
|
+
*/
|
|
163
|
+
unreportedMissCounts = new Map();
|
|
164
|
+
/** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
|
|
165
|
+
syncGeneration = 0;
|
|
166
|
+
/** Pending sync shared by overlapping {@link syncProposals} callers. */
|
|
167
|
+
syncProposalsInFlight;
|
|
107
168
|
constructor(account, config, guardian, signer, midenClient, accountId, midenRpcEndpoint, proverConfig, rpcConfig) {
|
|
108
169
|
this.account = account;
|
|
109
170
|
this.threshold = config.threshold;
|
|
@@ -251,6 +312,17 @@ export class Multisig {
|
|
|
251
312
|
threshold,
|
|
252
313
|
}));
|
|
253
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* The request options every `create*Proposal` hands its builder: the account,
|
|
317
|
+
* the caller's approval expiration, and the signer's scheme.
|
|
318
|
+
*/
|
|
319
|
+
proposalRequestOptions(options) {
|
|
320
|
+
return {
|
|
321
|
+
accountId: this._accountId,
|
|
322
|
+
approvalExpirationDelta: options.approvalExpirationDelta,
|
|
323
|
+
signatureScheme: this.signer.scheme,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
254
326
|
warnOnOverrideDilution(newNumSigners) {
|
|
255
327
|
const current = this.signerCommitments.length;
|
|
256
328
|
for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
|
|
@@ -267,11 +339,23 @@ export class Multisig {
|
|
|
267
339
|
* survive a switch, and the notes embedded in them can only be imported
|
|
268
340
|
* while the old GUARDIAN is still the current client.
|
|
269
341
|
*
|
|
342
|
+
* Repointing abandons a {@link syncProposals} still in flight: it rejects
|
|
343
|
+
* without applying its listing, though its request to the old GUARDIAN is
|
|
344
|
+
* not cancelled, so callers already awaiting it see the rejection only
|
|
345
|
+
* once that response settles. The reported-id and miss-count bookkeeping
|
|
346
|
+
* is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
|
|
347
|
+
* so proposals orphaned by the repoint expire through the two-miss rule
|
|
348
|
+
* instead of lingering.
|
|
349
|
+
*
|
|
270
350
|
* @param guardianClient - The new GUARDIAN HTTP client
|
|
271
351
|
*/
|
|
272
352
|
setGuardianClient(guardianClient) {
|
|
273
353
|
this.guardian = guardianClient;
|
|
274
354
|
this.guardian.setSigner(this.signer);
|
|
355
|
+
this.syncGeneration += 1;
|
|
356
|
+
this.syncProposalsInFlight = undefined;
|
|
357
|
+
this.lastReportedProposalIds = new Set();
|
|
358
|
+
this.unreportedMissCounts = new Map();
|
|
275
359
|
}
|
|
276
360
|
/**
|
|
277
361
|
* Fetch the current account state from GUARDIAN.
|
|
@@ -359,51 +443,15 @@ export class Multisig {
|
|
|
359
443
|
* not match the on-chain commitment.
|
|
360
444
|
*/
|
|
361
445
|
async isSafeToOverwriteLocalState(incomingAccount, localAccount) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (incomingNonce === localNonce) {
|
|
369
|
-
throw new Error(`Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
373
|
-
const onChainCommitment = await this.getOnChainCommitment(accountId);
|
|
374
|
-
if (!onChainCommitment) {
|
|
375
|
-
return true;
|
|
376
|
-
}
|
|
377
|
-
const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
|
|
378
|
-
if (incomingCommitment !== onChainCommitment) {
|
|
379
|
-
throw new Error(`Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`);
|
|
380
|
-
}
|
|
381
|
-
return true;
|
|
446
|
+
return isSafeToAdoptGuardianState({
|
|
447
|
+
accountId: this._accountId,
|
|
448
|
+
incomingAccount,
|
|
449
|
+
localAccount,
|
|
450
|
+
readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
|
|
451
|
+
});
|
|
382
452
|
}
|
|
383
453
|
async getOnChainCommitment(accountId) {
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
const accountDetails = await retryRpcRead(() => rpcClient.getAccountDetails(accountId), this.rpcConfig);
|
|
387
|
-
// If the account is not found or its commitment is zero, means that the account is not deployed yet
|
|
388
|
-
if (!accountDetails) {
|
|
389
|
-
return null;
|
|
390
|
-
}
|
|
391
|
-
const commitment = normalizeHexWord(accountDetails.commitment().toHex());
|
|
392
|
-
const zeroCommitment = `0x${'0'.repeat(64)}`;
|
|
393
|
-
if (commitment === zeroCommitment) {
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
return commitment;
|
|
397
|
-
}
|
|
398
|
-
catch (error) {
|
|
399
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
400
|
-
if (message.includes('null pointer passed to rust') ||
|
|
401
|
-
message.includes('No account header record found for given ID') ||
|
|
402
|
-
message.toLowerCase().includes('not found')) {
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
throw error;
|
|
406
|
-
}
|
|
454
|
+
return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
|
|
407
455
|
}
|
|
408
456
|
/**
|
|
409
457
|
* Sync the local store with the Miden node, then reload the cached account
|
|
@@ -474,18 +522,126 @@ export class Multisig {
|
|
|
474
522
|
}
|
|
475
523
|
}
|
|
476
524
|
/**
|
|
477
|
-
* Sync proposals from the GUARDIAN server
|
|
525
|
+
* Sync proposals from the GUARDIAN server, reconciling the local cache to
|
|
526
|
+
* the response. GUARDIAN reports only pending proposals, so a proposal it
|
|
527
|
+
* reported on an earlier sync and now omits is pruned immediately. A
|
|
528
|
+
* proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
|
|
529
|
+
* its read-your-writes has not caught up with, or a proposal orphaned by a
|
|
530
|
+
* {@link setGuardianClient} repoint) is pruned only after
|
|
531
|
+
* {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
|
|
532
|
+
* Proposals GUARDIAN never received (an `importProposal`, or a
|
|
533
|
+
* `createSwitchGuardianProposalOffline`) are not pruned by listings; an
|
|
534
|
+
* import graduates to the pruned classes once GUARDIAN acknowledges it
|
|
535
|
+
* (listed, or a successful online `signProposal`).
|
|
536
|
+
* Proposals cached or replaced after the sync started are not evaluated
|
|
537
|
+
* by it.
|
|
538
|
+
*
|
|
539
|
+
* Every synced proposal's metadata is checked against its signed summary
|
|
540
|
+
* and the outcome is recorded in {@link Proposal.verification}. One that
|
|
541
|
+
* fails is still cached and returned, so a single stale or corrupt
|
|
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
|
|
548
|
+
* refuse a failed proposal. A failed proposal still counts as reported, so
|
|
549
|
+
* it is pruned like any other once GUARDIAN stops listing it.
|
|
550
|
+
*
|
|
551
|
+
* The response is parsed in full before the cache or the pruning state
|
|
552
|
+
* changes; a payload that does not parse at all rejects and leaves both
|
|
553
|
+
* untouched, so malformed GUARDIAN data is never silently dropped.
|
|
554
|
+
* Signatures added to a cached proposal while the sync was verifying are
|
|
555
|
+
* preserved by its apply, and a proposal executed locally in that window
|
|
556
|
+
* stays `finalized` rather than reverting to the listed pending state.
|
|
557
|
+
* Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
|
|
558
|
+
* without applying its listing.
|
|
559
|
+
*
|
|
560
|
+
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
561
|
+
* `filterVisibleProposals`): callers of this shared client disagree on
|
|
562
|
+
* whether a proposal's `nonce` is the pre-execution or the next account
|
|
563
|
+
* nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
|
|
564
|
+
* cannot be applied here. This is an intentional TS/Rust surface
|
|
565
|
+
* difference.
|
|
478
566
|
*/
|
|
479
|
-
|
|
567
|
+
syncProposals() {
|
|
568
|
+
if (this.syncProposalsInFlight) {
|
|
569
|
+
return this.syncProposalsInFlight;
|
|
570
|
+
}
|
|
571
|
+
const inFlight = this.reconcileProposals().finally(() => {
|
|
572
|
+
if (this.syncProposalsInFlight === inFlight) {
|
|
573
|
+
this.syncProposalsInFlight = undefined;
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
this.syncProposalsInFlight = inFlight;
|
|
577
|
+
return inFlight;
|
|
578
|
+
}
|
|
579
|
+
async reconcileProposals() {
|
|
580
|
+
const generation = this.syncGeneration;
|
|
581
|
+
const candidates = new Map(this.proposals);
|
|
480
582
|
const deltas = await this.guardian.getDeltaProposals(this._accountId);
|
|
481
583
|
const factory = this.proposalFactory();
|
|
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
|
+
}
|
|
482
591
|
for (const delta of deltas) {
|
|
483
592
|
const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
|
|
484
593
|
const existingProposal = this.proposals.get(proposalId);
|
|
485
594
|
const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
|
|
486
|
-
|
|
595
|
+
// The outcome lands on the proposal either way; a failure is reported
|
|
596
|
+
// there rather than failing the sync.
|
|
597
|
+
await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
|
|
598
|
+
reported.set(proposal.id, { delta, verified: proposal });
|
|
599
|
+
}
|
|
600
|
+
if (generation !== this.syncGeneration) {
|
|
601
|
+
throw new Error('Sync aborted: the GUARDIAN client was replaced while the sync was in flight');
|
|
602
|
+
}
|
|
603
|
+
const applied = [];
|
|
604
|
+
for (const { delta, verified } of reported.values()) {
|
|
605
|
+
const current = this.proposals.get(verified.id);
|
|
606
|
+
if (current?.status === 'finalized') {
|
|
607
|
+
applied.push(current);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
applied.push(current === undefined
|
|
611
|
+
? verified
|
|
612
|
+
: {
|
|
613
|
+
...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
|
|
614
|
+
verification: verified.verification,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
for (const proposal of applied) {
|
|
487
618
|
this.proposals.set(proposal.id, proposal);
|
|
619
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
488
620
|
}
|
|
621
|
+
const missCounts = new Map();
|
|
622
|
+
for (const [id, snapshot] of candidates) {
|
|
623
|
+
if (reported.has(id) || this.proposals.get(id) !== snapshot) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (this.lastReportedProposalIds.has(id)) {
|
|
627
|
+
this.proposals.delete(id);
|
|
628
|
+
this.guardianKnownProposalIds.delete(id);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (!this.guardianKnownProposalIds.has(id)) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
|
|
635
|
+
if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
|
|
636
|
+
this.proposals.delete(id);
|
|
637
|
+
this.guardianKnownProposalIds.delete(id);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
missCounts.set(id, misses);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
this.unreportedMissCounts = missCounts;
|
|
644
|
+
this.lastReportedProposalIds = new Set(reported.keys());
|
|
489
645
|
return Array.from(this.proposals.values());
|
|
490
646
|
}
|
|
491
647
|
/**
|
|
@@ -539,7 +695,10 @@ export class Multisig {
|
|
|
539
695
|
return { proposals, skipped };
|
|
540
696
|
}
|
|
541
697
|
/**
|
|
542
|
-
*
|
|
698
|
+
* Returns the proposals cached by the most recent {@link syncProposals}
|
|
699
|
+
* call, plus any locally created or imported proposals GUARDIAN has not
|
|
700
|
+
* reported yet (see {@link syncProposals} for their retention). Not a
|
|
701
|
+
* durable history: proposals GUARDIAN no longer reports were pruned.
|
|
543
702
|
*/
|
|
544
703
|
listProposals() {
|
|
545
704
|
return Array.from(this.proposals.values());
|
|
@@ -565,6 +724,7 @@ export class Multisig {
|
|
|
565
724
|
const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
|
|
566
725
|
await this.verifyProposalMetadataBinding(proposal);
|
|
567
726
|
this.proposals.set(proposal.id, proposal);
|
|
727
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
568
728
|
return proposal;
|
|
569
729
|
}
|
|
570
730
|
/**
|
|
@@ -579,8 +739,16 @@ export class Multisig {
|
|
|
579
739
|
const webClient = await this.getRawClient();
|
|
580
740
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
581
741
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
742
|
+
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
743
|
+
// procedure asserts on every transaction after the update, checked before
|
|
744
|
+
// any signature is collected.
|
|
745
|
+
validateMultisigConfig({
|
|
746
|
+
threshold: targetThreshold,
|
|
747
|
+
signerCommitments: targetSignerCommitments,
|
|
748
|
+
guardianCommitment: this.guardianCommitment,
|
|
749
|
+
});
|
|
582
750
|
this.warnOnOverrideDilution(targetSignerCommitments.length);
|
|
583
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments,
|
|
751
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
584
752
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
585
753
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
586
754
|
anchor.free();
|
|
@@ -618,7 +786,7 @@ export class Multisig {
|
|
|
618
786
|
if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
|
|
619
787
|
throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
|
|
620
788
|
}
|
|
621
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments,
|
|
789
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
622
790
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
623
791
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
624
792
|
anchor.free();
|
|
@@ -649,7 +817,7 @@ export class Multisig {
|
|
|
649
817
|
if (newThreshold === this.threshold) {
|
|
650
818
|
throw new Error('New threshold is the same as current threshold');
|
|
651
819
|
}
|
|
652
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments,
|
|
820
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
|
|
653
821
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
654
822
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
655
823
|
anchor.free();
|
|
@@ -678,7 +846,7 @@ export class Multisig {
|
|
|
678
846
|
if (currentOverride !== undefined && currentOverride === targetThreshold) {
|
|
679
847
|
throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
|
|
680
848
|
}
|
|
681
|
-
const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold,
|
|
849
|
+
const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
|
|
682
850
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
683
851
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
684
852
|
anchor.free();
|
|
@@ -706,7 +874,7 @@ export class Multisig {
|
|
|
706
874
|
*/
|
|
707
875
|
async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
|
|
708
876
|
const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
|
|
709
|
-
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
|
|
877
|
+
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
710
878
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
711
879
|
// sign/execute (which fetch from GUARDIAN) can find it. To leave an
|
|
712
880
|
// unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
|
|
@@ -718,10 +886,21 @@ export class Multisig {
|
|
|
718
886
|
* for its summary and metadata. Kept in one place so the online and offline
|
|
719
887
|
* proposals for the same operation can never drift apart.
|
|
720
888
|
*/
|
|
721
|
-
async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey) {
|
|
889
|
+
async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, approvalExpirationDelta) {
|
|
722
890
|
const webClient = await this.getRawClient();
|
|
891
|
+
// What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
|
|
892
|
+
// before any signature is collected.
|
|
893
|
+
validateMultisigConfig({
|
|
894
|
+
threshold: this.threshold,
|
|
895
|
+
signerCommitments: [...this.signerCommitments],
|
|
896
|
+
guardianCommitment: newGuardianPubkey,
|
|
897
|
+
});
|
|
723
898
|
await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
|
|
724
|
-
const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
|
|
899
|
+
const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
|
|
900
|
+
accountId: this._accountId,
|
|
901
|
+
approvalExpirationDelta,
|
|
902
|
+
signatureScheme: this.signer.scheme,
|
|
903
|
+
});
|
|
725
904
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
726
905
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
727
906
|
anchor.free();
|
|
@@ -770,7 +949,7 @@ export class Multisig {
|
|
|
770
949
|
// state — or a readiness threshold read from stale config — would only
|
|
771
950
|
// fail at execution, after the whole side-channel cosigning ceremony.
|
|
772
951
|
await this.syncNetworkOnly();
|
|
773
|
-
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
|
|
952
|
+
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
774
953
|
const exported = {
|
|
775
954
|
accountId: this._accountId,
|
|
776
955
|
nonce: proposalNonce,
|
|
@@ -808,8 +987,12 @@ export class Multisig {
|
|
|
808
987
|
}
|
|
809
988
|
fetchedNotes.push(inputNoteRecord.toNote());
|
|
810
989
|
}
|
|
990
|
+
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
991
|
+
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
992
|
+
// the notes are authenticated here first, before the anchor is captured.
|
|
993
|
+
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
811
994
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
812
|
-
const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
|
|
995
|
+
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(webClient, fetchedNotes, this.proposalRequestOptions(options));
|
|
813
996
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
814
997
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
815
998
|
anchor.free();
|
|
@@ -854,7 +1037,7 @@ export class Multisig {
|
|
|
854
1037
|
// Forward everything but the nonce, so a note option added to
|
|
855
1038
|
// CreateP2idProposalOptions can't be silently dropped before the builder.
|
|
856
1039
|
const { nonce: _nonce, ...noteOptions } = options;
|
|
857
|
-
const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, noteOptions);
|
|
1040
|
+
const { request, salt } = await buildP2idTransactionRequest(webClient, this._accountId, recipientId, faucetId, amount, noteOptions);
|
|
858
1041
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
859
1042
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
860
1043
|
anchor.free();
|
|
@@ -1021,6 +1204,17 @@ export class Multisig {
|
|
|
1021
1204
|
* store, reusing this client's Miden RPC endpoint and retry
|
|
1022
1205
|
* configuration.
|
|
1023
1206
|
*/
|
|
1207
|
+
/**
|
|
1208
|
+
* Puts the local store in the canonical (authenticated) consumption mode
|
|
1209
|
+
* for `notes`, fetching missing inclusion proofs from this client's Miden
|
|
1210
|
+
* node; see {@link ensureNotesAuthenticated}.
|
|
1211
|
+
*/
|
|
1212
|
+
async ensureNotesAuthenticated(notes) {
|
|
1213
|
+
await ensureNotesAuthenticated(this.midenClient, notes, {
|
|
1214
|
+
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
1215
|
+
rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1024
1218
|
async importNotesFromProposals(proposals, cancelled) {
|
|
1025
1219
|
return importNotesFromProposalsStandalone(this.midenClient, proposals, {
|
|
1026
1220
|
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
@@ -1277,6 +1471,8 @@ export class Multisig {
|
|
|
1277
1471
|
}
|
|
1278
1472
|
async signProposal(proposalId) {
|
|
1279
1473
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1474
|
+
// Verification re-executes the proposal at the store's sync height.
|
|
1475
|
+
await this.syncChain();
|
|
1280
1476
|
const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
|
|
1281
1477
|
if (!existingProposal) {
|
|
1282
1478
|
throw new Error(`Proposal not found: ${proposalId}`);
|
|
@@ -1294,6 +1490,7 @@ export class Multisig {
|
|
|
1294
1490
|
const signedProposal = factory.fromDelta(signedDelta, normalizedProposalId, proposal.metadata, proposal.signatures);
|
|
1295
1491
|
await this.verifyProposalMetadataBinding(signedProposal);
|
|
1296
1492
|
this.proposals.set(signedProposal.id, signedProposal);
|
|
1493
|
+
this.guardianKnownProposalIds.add(signedProposal.id);
|
|
1297
1494
|
return signedProposal;
|
|
1298
1495
|
}
|
|
1299
1496
|
async getProposalForSigning(proposalId, normalizedProposalId) {
|
|
@@ -1304,6 +1501,12 @@ export class Multisig {
|
|
|
1304
1501
|
await this.syncProposals();
|
|
1305
1502
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
1306
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
|
+
*/
|
|
1307
1510
|
async createTransactionProposalRequest(proposalId) {
|
|
1308
1511
|
const { finalRequest } = await this.prepareProposalExecution(proposalId);
|
|
1309
1512
|
return finalRequest;
|
|
@@ -1321,17 +1524,9 @@ export class Multisig {
|
|
|
1321
1524
|
// best-effort and bounded — see preservePreSwitchProposalNotes.
|
|
1322
1525
|
await this.preservePreSwitchProposalNotes();
|
|
1323
1526
|
}
|
|
1324
|
-
// Execute at the
|
|
1325
|
-
//
|
|
1326
|
-
|
|
1327
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
1328
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1329
|
-
try {
|
|
1330
|
-
await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
|
|
1331
|
-
}
|
|
1332
|
-
finally {
|
|
1333
|
-
anchor.free();
|
|
1334
|
-
}
|
|
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);
|
|
1335
1530
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1336
1531
|
if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
|
|
1337
1532
|
throw new Error('Switch GUARDIAN proposal metadata is incomplete after execution');
|
|
@@ -1371,17 +1566,18 @@ export class Multisig {
|
|
|
1371
1566
|
throw new Error(`Transaction executed successfully but failed to register on new GUARDIAN: ${message}`);
|
|
1372
1567
|
}
|
|
1373
1568
|
}
|
|
1374
|
-
proposal.status
|
|
1569
|
+
this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
|
|
1375
1570
|
}
|
|
1376
1571
|
/**
|
|
1377
1572
|
* Submit an integration-built transaction (advice already injected). Mirrors
|
|
1378
1573
|
* the Rust `submit_transaction`; used by the custom proposal producer flow
|
|
1379
1574
|
* after `prepareCustomExecution` rebuilds its request with the returned advice.
|
|
1380
|
-
* The transaction is executed at the
|
|
1381
|
-
*
|
|
1575
|
+
* The transaction is executed at the chain tip, so the request has to declare
|
|
1576
|
+
* the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
|
|
1382
1577
|
*/
|
|
1383
1578
|
async submitTransaction(proposalId, request) {
|
|
1384
1579
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1580
|
+
await this.syncChain();
|
|
1385
1581
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1386
1582
|
const existing = this.getLocalProposal(proposalId);
|
|
1387
1583
|
const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
|
|
@@ -1393,11 +1589,34 @@ export class Multisig {
|
|
|
1393
1589
|
if (anchorCommitment !== summaryBlockCommitment) {
|
|
1394
1590
|
throw new Error(`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
|
|
1395
1591
|
}
|
|
1396
|
-
await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
|
|
1397
1592
|
}
|
|
1398
1593
|
finally {
|
|
1399
1594
|
anchor.free();
|
|
1400
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));
|
|
1401
1620
|
}
|
|
1402
1621
|
/**
|
|
1403
1622
|
* Create a proposal from a producer-built transaction the SDK does not model.
|
|
@@ -1445,6 +1664,8 @@ export class Multisig {
|
|
|
1445
1664
|
*/
|
|
1446
1665
|
async prepareCustomExecution(proposalId, transactionRequestBytes) {
|
|
1447
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();
|
|
1448
1669
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1449
1670
|
const existing = this.getLocalProposal(proposalId);
|
|
1450
1671
|
const proposal = this.proposalFactory().fromDelta(delta, normalizedProposalId, existing?.metadata, existing?.signatures ?? []);
|
|
@@ -1459,31 +1680,45 @@ export class Multisig {
|
|
|
1459
1680
|
const txSummary = TransactionSummary.deserialize(base64ToUint8Array(delta.deltaPayload.txSummary.data));
|
|
1460
1681
|
const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
|
|
1461
1682
|
const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
// 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.
|
|
1467
1687
|
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1468
|
-
let derivedCommitmentHex;
|
|
1469
1688
|
try {
|
|
1470
1689
|
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
1471
1690
|
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
1472
1691
|
if (anchorCommitment !== summaryBlockCommitment) {
|
|
1473
1692
|
throw new Error(`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`);
|
|
1474
1693
|
}
|
|
1475
|
-
const webClient = await this.getRawClient();
|
|
1476
|
-
const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
|
|
1477
|
-
derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
1478
1694
|
}
|
|
1479
1695
|
finally {
|
|
1480
1696
|
anchor.free();
|
|
1481
1697
|
}
|
|
1698
|
+
const webClient = await this.getRawClient();
|
|
1699
|
+
const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
|
|
1700
|
+
const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
1482
1701
|
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
1483
1702
|
throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
|
|
1484
1703
|
}
|
|
1704
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
1485
1705
|
return this.assembleCustomAdvice(proposalId, signaturesForExecution, signedCommitmentHex, delta);
|
|
1486
1706
|
}
|
|
1707
|
+
buildCosignerAdviceEntry(cosignerSig, signerCommitment, txCommitmentHex) {
|
|
1708
|
+
const approval = cosignerSig.signature;
|
|
1709
|
+
const txCommitment = Word.fromHex(txCommitmentHex);
|
|
1710
|
+
if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
|
|
1711
|
+
if (!approval.publicKey) {
|
|
1712
|
+
throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
|
|
1713
|
+
}
|
|
1714
|
+
return buildEip712SignatureAdviceEntry(signerCommitment, txCommitment, approval.signature, approval.publicKey);
|
|
1715
|
+
}
|
|
1716
|
+
const signature = Signature.deserialize(signatureHexToBytes(approval.signature, approval.scheme));
|
|
1717
|
+
if (approval.scheme === 'ecdsa' && approval.publicKey) {
|
|
1718
|
+
assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
|
|
1719
|
+
}
|
|
1720
|
+
return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
|
|
1721
|
+
}
|
|
1487
1722
|
async assembleCustomAdvice(proposalId, signaturesForExecution, normalizedTxCommitmentHex, delta) {
|
|
1488
1723
|
const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
|
|
1489
1724
|
const adviceMap = new AdviceMap();
|
|
@@ -1505,12 +1740,7 @@ export class Multisig {
|
|
|
1505
1740
|
}
|
|
1506
1741
|
}
|
|
1507
1742
|
const signerCommitment = Word.fromHex(signerCommitmentHex);
|
|
1508
|
-
const
|
|
1509
|
-
const signature = Signature.deserialize(sigBytes);
|
|
1510
|
-
if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
|
|
1511
|
-
assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
|
|
1512
|
-
}
|
|
1513
|
-
const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
|
|
1743
|
+
const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
|
|
1514
1744
|
const keyHex = normalizeHexWord(key.toHex());
|
|
1515
1745
|
if (adviceMapKeys.has(keyHex)) {
|
|
1516
1746
|
throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
|
|
@@ -1554,12 +1784,18 @@ export class Multisig {
|
|
|
1554
1784
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1555
1785
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
1556
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* The returned request declares the block the signed summary binds, and
|
|
1789
|
+
* executes at the chain tip.
|
|
1790
|
+
*/
|
|
1557
1791
|
async prepareProposalExecution(proposalId) {
|
|
1558
1792
|
const proposal = this.getLocalProposal(proposalId);
|
|
1559
1793
|
if (!proposal) {
|
|
1560
1794
|
throw new Error(`Proposal not found: ${proposalId}`);
|
|
1561
1795
|
}
|
|
1562
1796
|
this.proposalFactory().assertAccountId(proposal.accountId);
|
|
1797
|
+
// Verification and execution run at the store's sync height.
|
|
1798
|
+
await this.syncChain();
|
|
1563
1799
|
await this.verifyProposalMetadataBinding(proposal);
|
|
1564
1800
|
const metadata = proposal.metadata;
|
|
1565
1801
|
// Reject custom proposals before any advice assembly or GUARDIAN ack push:
|
|
@@ -1601,6 +1837,7 @@ export class Multisig {
|
|
|
1601
1837
|
throw new Error(`Proposal ${proposalId} tx_summary commitment ${normalizedTxCommitmentHex} ` +
|
|
1602
1838
|
'does not match the proposal id it belongs to');
|
|
1603
1839
|
}
|
|
1840
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
1604
1841
|
const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
|
|
1605
1842
|
const adviceMap = new AdviceMap();
|
|
1606
1843
|
const adviceMapKeys = new Set();
|
|
@@ -1623,12 +1860,7 @@ export class Multisig {
|
|
|
1623
1860
|
}
|
|
1624
1861
|
}
|
|
1625
1862
|
const signerCommitment = Word.fromHex(signerCommitmentHex);
|
|
1626
|
-
const
|
|
1627
|
-
const signature = Signature.deserialize(sigBytes);
|
|
1628
|
-
if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
|
|
1629
|
-
assertEcdsaSignatureRecoverable(cosignerSig.signature.signature, normalizedTxCommitmentHex, ecdsaPublicKey);
|
|
1630
|
-
}
|
|
1631
|
-
const { key, values } = buildSignatureAdviceEntry(signerCommitment, createTxCommitmentWord(), signature);
|
|
1863
|
+
const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
|
|
1632
1864
|
const keyHex = normalizeHexWord(key.toHex());
|
|
1633
1865
|
if (adviceMapKeys.has(keyHex)) {
|
|
1634
1866
|
throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
|
|
@@ -1674,16 +1906,18 @@ export class Multisig {
|
|
|
1674
1906
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1675
1907
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
1676
1908
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
|
|
1680
|
-
let finalRequest;
|
|
1909
|
+
const anchor = this.requireProposalAnchor(proposalId, metadata);
|
|
1910
|
+
let binding;
|
|
1681
1911
|
try {
|
|
1682
|
-
|
|
1912
|
+
binding = proposalRequestBinding(txSummary, anchor, saltHex);
|
|
1683
1913
|
}
|
|
1684
1914
|
finally {
|
|
1685
|
-
|
|
1915
|
+
anchor.free();
|
|
1686
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);
|
|
1687
1921
|
return { finalRequest, metadata, proposal };
|
|
1688
1922
|
}
|
|
1689
1923
|
/**
|
|
@@ -1699,6 +1933,8 @@ export class Multisig {
|
|
|
1699
1933
|
signatureHex: s.signature.signature,
|
|
1700
1934
|
scheme: s.signature.scheme,
|
|
1701
1935
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
1936
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
1937
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
1702
1938
|
timestamp: s.timestamp,
|
|
1703
1939
|
}))
|
|
1704
1940
|
: [];
|
|
@@ -1732,6 +1968,8 @@ export class Multisig {
|
|
|
1732
1968
|
signatureHex: s.signature.signature,
|
|
1733
1969
|
scheme: s.signature.scheme,
|
|
1734
1970
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
1971
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
1972
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
1735
1973
|
timestamp: s.timestamp,
|
|
1736
1974
|
})),
|
|
1737
1975
|
metadata: proposal.metadata,
|
|
@@ -1750,6 +1988,10 @@ export class Multisig {
|
|
|
1750
1988
|
throw new Error('Invalid proposal JSON: missing required fields');
|
|
1751
1989
|
}
|
|
1752
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
|
+
}
|
|
1753
1995
|
await this.verifyProposalMetadataBinding(proposal);
|
|
1754
1996
|
this.proposals.set(proposal.id, proposal);
|
|
1755
1997
|
return proposal;
|
|
@@ -1782,6 +2024,10 @@ export class Multisig {
|
|
|
1782
2024
|
if (alreadySigned) {
|
|
1783
2025
|
throw new Error('You have already signed this proposal');
|
|
1784
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
|
+
}
|
|
1785
2031
|
const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
|
|
1786
2032
|
// Sign the commitment
|
|
1787
2033
|
const signature = await buildGuardianSignatureFromSigner(this.signer, commitmentToSign);
|
|
@@ -1795,13 +2041,17 @@ export class Multisig {
|
|
|
1795
2041
|
},
|
|
1796
2042
|
];
|
|
1797
2043
|
const canonicalizedSignatures = new ProposalSignatures(signatures, this.signerCommitments, localSignatureContext).entries();
|
|
1798
|
-
proposal.signatures = canonicalizedSignatures;
|
|
1799
|
-
// Update status
|
|
1800
2044
|
const proposalType = proposal.metadata?.proposalType;
|
|
1801
2045
|
const signaturesRequired = proposalType
|
|
1802
2046
|
? this.getEffectiveThreshold(proposalType)
|
|
1803
2047
|
: this.threshold;
|
|
1804
|
-
|
|
2048
|
+
// A fresh object rather than an in-place write: a sync that snapshotted
|
|
2049
|
+
// the cache before this signature tells the two apart by identity.
|
|
2050
|
+
this.proposals.set(proposal.id, {
|
|
2051
|
+
...proposal,
|
|
2052
|
+
signatures: canonicalizedSignatures,
|
|
2053
|
+
status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
|
|
2054
|
+
});
|
|
1805
2055
|
// Return updated JSON
|
|
1806
2056
|
return this.exportProposalToJson(proposal.id);
|
|
1807
2057
|
}
|
|
@@ -1813,13 +2063,36 @@ export class Multisig {
|
|
|
1813
2063
|
}
|
|
1814
2064
|
return txSummaryCommitment;
|
|
1815
2065
|
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Verifies that a proposal's metadata reconstructs its signed summary
|
|
2068
|
+
* commitment and records the outcome in {@link Proposal.verification}:
|
|
2069
|
+
* `verified`, or `failed` with the message and whether the failure looked
|
|
2070
|
+
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2071
|
+
* while `syncProposals` keeps going with the outcome recorded.
|
|
2072
|
+
*/
|
|
1816
2073
|
async verifyProposalMetadataBinding(proposal) {
|
|
2074
|
+
try {
|
|
2075
|
+
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2076
|
+
proposal.verification = { status: 'verified' };
|
|
2077
|
+
return commitment;
|
|
2078
|
+
}
|
|
2079
|
+
catch (error) {
|
|
2080
|
+
proposal.verification = {
|
|
2081
|
+
status: 'failed',
|
|
2082
|
+
retryable: isTransientRpcError(error) || isStaleChainError(error),
|
|
2083
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2084
|
+
};
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
async checkProposalMetadataBinding(proposal) {
|
|
1817
2089
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
1818
2090
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
1819
|
-
// The anchor arrives from an untrusted party via GUARDIAN, so check
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
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.
|
|
1823
2096
|
const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
|
|
1824
2097
|
try {
|
|
1825
2098
|
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
@@ -1833,15 +2106,35 @@ export class Multisig {
|
|
|
1833
2106
|
// integrity guarantee for an opaque proposal.
|
|
1834
2107
|
return txSummaryCommitment;
|
|
1835
2108
|
}
|
|
2109
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2110
|
+
// type, switch_guardian included (as in the Rust SDK): a mismatched salt
|
|
2111
|
+
// would otherwise collect signatures and only fail in the VM.
|
|
2112
|
+
const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
|
|
2113
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2114
|
+
throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
|
|
2115
|
+
}
|
|
1836
2116
|
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
1837
|
-
// Re-execution would mutate the WASM account twice. The proposal ID
|
|
1838
|
-
//
|
|
2117
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2118
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2119
|
+
// binding checks for this type.
|
|
1839
2120
|
return txSummaryCommitment;
|
|
1840
2121
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
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);
|
|
1843
2127
|
const webClient = await this.getRawClient();
|
|
1844
|
-
|
|
2128
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2129
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2130
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2131
|
+
// that never held these notes reproduces a different commitment.
|
|
2132
|
+
if (proposal.metadata.proposalType === 'consume_notes' &&
|
|
2133
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
2134
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2135
|
+
}
|
|
2136
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2137
|
+
const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
|
|
1845
2138
|
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
1846
2139
|
if (reconstructedCommitment !== txSummaryCommitment) {
|
|
1847
2140
|
throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
|
|
@@ -1852,32 +2145,21 @@ export class Multisig {
|
|
|
1852
2145
|
anchor.free();
|
|
1853
2146
|
}
|
|
1854
2147
|
}
|
|
1855
|
-
/**
|
|
1856
|
-
* Decodes a proposal's chain anchor. Throws when absent: a proposal without
|
|
1857
|
-
* an anchor was created at an unknown reference block, so its signed summary
|
|
1858
|
-
* cannot be reproduced, verified, or executed. The caller owns the returned
|
|
1859
|
-
* anchor and must `free()` it once done.
|
|
1860
|
-
*/
|
|
1861
2148
|
/**
|
|
1862
2149
|
* Reads a proposal's salt. Throws when absent, because there is nothing to fall
|
|
1863
2150
|
* back to.
|
|
1864
2151
|
*
|
|
1865
|
-
* The
|
|
1866
|
-
*
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
* why the fallback this replaces was correct when it was written.
|
|
1871
|
-
*
|
|
1872
|
-
* A declared salt also bypasses miden-client's zero-fee early return, so this holds
|
|
1873
|
-
* on a chain that charges nothing exactly as on one that charges.
|
|
2152
|
+
* The salt goes into the request's multisig auth args and the summary binds it in
|
|
2153
|
+
* its user params, so `summarySalt(summary)` reads the value the cosigners signed
|
|
2154
|
+
* over. It is not a substitute for this field: a request has to be rebuilt before
|
|
2155
|
+
* any summary exists, and a proposal GUARDIAN serves may pair a summary with
|
|
2156
|
+
* metadata that names another salt, which the binding check reports by name.
|
|
1874
2157
|
*/
|
|
1875
2158
|
requireProposalSaltHex(proposalId, metadata) {
|
|
1876
2159
|
const saltHex = metadata.saltHex;
|
|
1877
2160
|
if (saltHex === undefined || saltHex === null || saltHex === '') {
|
|
1878
|
-
throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt
|
|
1879
|
-
'
|
|
1880
|
-
'to the salt');
|
|
2161
|
+
throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
|
|
2162
|
+
'salt its auth args and signed summary bind');
|
|
1881
2163
|
}
|
|
1882
2164
|
// GUARDIAN serves this field and the response is cast, not parsed, so everything
|
|
1883
2165
|
// below is untrusted input. A truthiness test is not enough: `normalizeHexWord`
|
|
@@ -1904,54 +2186,81 @@ export class Multisig {
|
|
|
1904
2186
|
}
|
|
1905
2187
|
return saltHex;
|
|
1906
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
|
+
*/
|
|
1907
2195
|
requireProposalAnchor(proposalId, metadata) {
|
|
1908
2196
|
if (!metadata.chainAnchor) {
|
|
1909
|
-
throw new Error(`Proposal ${proposalId} has no chain anchor
|
|
1910
|
-
'
|
|
1911
|
-
'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');
|
|
1912
2199
|
}
|
|
1913
2200
|
return chainAnchorFromBase64(metadata.chainAnchor);
|
|
1914
2201
|
}
|
|
1915
|
-
|
|
2202
|
+
/**
|
|
2203
|
+
* An expired approval aborts in the auth procedure only at execution.
|
|
2204
|
+
* The summary carries the deadline, so callers check it against the sync
|
|
2205
|
+
* height before assembling advice or requesting the GUARDIAN ack.
|
|
2206
|
+
*/
|
|
2207
|
+
async assertApprovalNotExpired(proposalId, summary) {
|
|
2208
|
+
const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
|
|
2209
|
+
if (expirationBlockNum === undefined) {
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const webClient = await this.getRawClient();
|
|
2213
|
+
const syncHeight = await webClient.getSyncHeight();
|
|
2214
|
+
if (syncHeight >= expirationBlockNum) {
|
|
2215
|
+
throw new Error(`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
|
|
2216
|
+
`block ${syncHeight}, so the collected signatures no longer authorize it`);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Rebuilds a proposal's request from its metadata under `binding`, so the
|
|
2221
|
+
* summary it produces is the one the cosigners signed.
|
|
2222
|
+
*/
|
|
2223
|
+
async buildTransactionRequestFromMetadata(metadata, binding, signatureAdviceMap) {
|
|
2224
|
+
// The builders read `.toHex()` and allocate their own Word, so this handle
|
|
2225
|
+
// stays ours; without the release it leaks once per rebuild.
|
|
2226
|
+
const salt = Word.fromHex(binding.saltHex);
|
|
2227
|
+
try {
|
|
2228
|
+
return await this.buildTransactionRequestWithOptions(metadata, {
|
|
2229
|
+
accountId: this._accountId,
|
|
2230
|
+
boundBlockNum: binding.boundBlockNum,
|
|
2231
|
+
approvalExpirationDelta: binding.approvalExpirationDelta,
|
|
2232
|
+
salt,
|
|
2233
|
+
signatureAdviceMap,
|
|
2234
|
+
signatureScheme: this.signer.scheme,
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
finally {
|
|
2238
|
+
salt.free?.();
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
async buildTransactionRequestWithOptions(metadata, requestOptions) {
|
|
1916
2242
|
const webClient = await this.getRawClient();
|
|
1917
2243
|
switch (metadata.proposalType) {
|
|
1918
2244
|
case 'add_signer':
|
|
1919
2245
|
case 'remove_signer':
|
|
1920
2246
|
case 'change_threshold': {
|
|
1921
|
-
const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments,
|
|
2247
|
+
const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, requestOptions);
|
|
1922
2248
|
return request;
|
|
1923
2249
|
}
|
|
1924
2250
|
case 'switch_guardian': {
|
|
1925
|
-
const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey,
|
|
2251
|
+
const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, requestOptions);
|
|
1926
2252
|
return request;
|
|
1927
2253
|
}
|
|
1928
2254
|
case 'update_procedure_threshold': {
|
|
1929
|
-
const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold,
|
|
2255
|
+
const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, requestOptions);
|
|
1930
2256
|
return request;
|
|
1931
2257
|
}
|
|
1932
2258
|
case 'consume_notes': {
|
|
1933
2259
|
// v1/v2 dispatch for issue #229 / FR-009.
|
|
1934
2260
|
const version = metadata.metadataVersion;
|
|
1935
2261
|
if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
1936
|
-
const
|
|
1937
|
-
|
|
1938
|
-
throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${metadata.noteIds.length}`);
|
|
1939
|
-
}
|
|
1940
|
-
const decoded = [];
|
|
1941
|
-
for (let i = 0; i < embedded.length; i++) {
|
|
1942
|
-
const note = noteFromBase64(embedded[i], Note);
|
|
1943
|
-
// Normalize both sides; matches the file's other hex comparisons.
|
|
1944
|
-
const embeddedId = normalizeHexWord(note.id().toString());
|
|
1945
|
-
const declaredId = normalizeHexWord(metadata.noteIds[i]);
|
|
1946
|
-
if (embeddedId !== declaredId) {
|
|
1947
|
-
throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
|
|
1948
|
-
}
|
|
1949
|
-
decoded.push(note);
|
|
1950
|
-
}
|
|
1951
|
-
const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
|
|
1952
|
-
salt,
|
|
1953
|
-
signatureAdviceMap,
|
|
1954
|
-
});
|
|
2262
|
+
const decoded = decodeEmbeddedConsumeNotes(metadata);
|
|
2263
|
+
const { request } = await buildConsumeNotesTransactionRequestFromNotes(webClient, decoded, requestOptions);
|
|
1955
2264
|
return request;
|
|
1956
2265
|
}
|
|
1957
2266
|
if (version === undefined || version === 1) {
|
|
@@ -1960,15 +2269,14 @@ export class Multisig {
|
|
|
1960
2269
|
// operator which legacy shape was rejected.
|
|
1961
2270
|
throw new UnsupportedMetadataVersionError(version);
|
|
1962
2271
|
}
|
|
1963
|
-
const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds,
|
|
2272
|
+
const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, requestOptions);
|
|
1964
2273
|
return request;
|
|
1965
2274
|
}
|
|
1966
2275
|
throw new UnsupportedMetadataVersionError(version);
|
|
1967
2276
|
}
|
|
1968
2277
|
case 'p2id': {
|
|
1969
|
-
const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
|
|
1970
|
-
|
|
1971
|
-
signatureAdviceMap,
|
|
2278
|
+
const { request } = await buildP2idTransactionRequest(webClient, this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
|
|
2279
|
+
...requestOptions,
|
|
1972
2280
|
noteType: parseP2idNoteType(metadata.noteType),
|
|
1973
2281
|
reclaimHeight: metadata.reclaimHeight,
|
|
1974
2282
|
timelockHeight: metadata.timelockHeight,
|
|
@@ -1980,4 +2288,27 @@ export class Multisig {
|
|
|
1980
2288
|
}
|
|
1981
2289
|
}
|
|
1982
2290
|
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
|
|
2293
|
+
* is the note its declared id names (spec 006 FR-007).
|
|
2294
|
+
*/
|
|
2295
|
+
function decodeEmbeddedConsumeNotes(metadata) {
|
|
2296
|
+
const embedded = metadata.notes ?? [];
|
|
2297
|
+
const noteIds = metadata.noteIds ?? [];
|
|
2298
|
+
if (embedded.length !== noteIds.length) {
|
|
2299
|
+
throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`);
|
|
2300
|
+
}
|
|
2301
|
+
const decoded = [];
|
|
2302
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
2303
|
+
const note = noteFromBase64(embedded[i], Note);
|
|
2304
|
+
// Normalize both sides; matches the file's other hex comparisons.
|
|
2305
|
+
const embeddedId = normalizeHexWord(note.id().toString());
|
|
2306
|
+
const declaredId = normalizeHexWord(noteIds[i]);
|
|
2307
|
+
if (embeddedId !== declaredId) {
|
|
2308
|
+
throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
|
|
2309
|
+
}
|
|
2310
|
+
decoded.push(note);
|
|
2311
|
+
}
|
|
2312
|
+
return decoded;
|
|
2313
|
+
}
|
|
1983
2314
|
//# sourceMappingURL=multisig.js.map
|