@openzeppelin/miden-multisig-client 0.17.0 → 0.18.0-rc.1
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 +159 -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 +14 -31
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +22 -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 +118 -11
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +421 -141
- 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/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 +41 -15
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +71 -19
- 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 +31 -0
- 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 +21 -3
- package/src/multisig/authArgErrors.ts +23 -56
- package/src/multisig/consumeNotesErrors.ts +20 -1
- package/src/multisig/signing.ts +8 -2
- package/src/multisig.ts +530 -183
- package/src/procedures.ts +7 -7
- package/src/proposal/factory.ts +7 -0
- 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 +86 -22
- 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 +12 -1
- package/src/types/proposal.ts +30 -0
- 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, executeForSummaryAt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
|
|
9
|
+
import { Account, AccountId, AdviceMap, FeltArray, Note, NoteExportFormat, NoteFile, Signature, TransactionRequest, TransactionSummary, Word, } from '@miden-sdk/miden-sdk';
|
|
10
|
+
import { chainAnchorFromBase64, chainAnchorToBase64, executeForSummary, executeForSummaryAt, summaryApprovalExpirationBlockNum, summarySalt, buildUpdateSignersTransactionRequest, buildUpdateProcedureThresholdTransactionRequest, buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idNoteFromMetadata, buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata, } from './transaction.js';
|
|
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,119 @@ 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: once the node prunes a
|
|
543
|
+
* proposal's anchor block its re-execution fails for everyone). `failed`
|
|
544
|
+
* with `retryable: true` means a transient node error, worth syncing
|
|
545
|
+
* again; `retryable: false` means the proposal cannot be reproduced and
|
|
546
|
+
* must be re-proposed. `signProposal` and `executeProposal` re-verify and
|
|
547
|
+
* refuse a failed proposal. A failed proposal still counts as reported, so
|
|
548
|
+
* it is pruned like any other once GUARDIAN stops listing it.
|
|
549
|
+
*
|
|
550
|
+
* The response is parsed in full before the cache or the pruning state
|
|
551
|
+
* changes; a payload that does not parse at all rejects and leaves both
|
|
552
|
+
* untouched, so malformed GUARDIAN data is never silently dropped.
|
|
553
|
+
* Signatures added to a cached proposal while the sync was verifying are
|
|
554
|
+
* preserved by its apply, and a proposal executed locally in that window
|
|
555
|
+
* stays `finalized` rather than reverting to the listed pending state.
|
|
556
|
+
* Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
|
|
557
|
+
* without applying its listing.
|
|
558
|
+
*
|
|
559
|
+
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
560
|
+
* `filterVisibleProposals`): callers of this shared client disagree on
|
|
561
|
+
* whether a proposal's `nonce` is the pre-execution or the next account
|
|
562
|
+
* nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
|
|
563
|
+
* cannot be applied here. This is an intentional TS/Rust surface
|
|
564
|
+
* difference.
|
|
478
565
|
*/
|
|
479
|
-
|
|
566
|
+
syncProposals() {
|
|
567
|
+
if (this.syncProposalsInFlight) {
|
|
568
|
+
return this.syncProposalsInFlight;
|
|
569
|
+
}
|
|
570
|
+
const inFlight = this.reconcileProposals().finally(() => {
|
|
571
|
+
if (this.syncProposalsInFlight === inFlight) {
|
|
572
|
+
this.syncProposalsInFlight = undefined;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
this.syncProposalsInFlight = inFlight;
|
|
576
|
+
return inFlight;
|
|
577
|
+
}
|
|
578
|
+
async reconcileProposals() {
|
|
579
|
+
const generation = this.syncGeneration;
|
|
580
|
+
const candidates = new Map(this.proposals);
|
|
480
581
|
const deltas = await this.guardian.getDeltaProposals(this._accountId);
|
|
481
582
|
const factory = this.proposalFactory();
|
|
583
|
+
const reported = new Map();
|
|
482
584
|
for (const delta of deltas) {
|
|
483
585
|
const proposalId = normalizeHexWord(computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data));
|
|
484
586
|
const existingProposal = this.proposals.get(proposalId);
|
|
485
587
|
const proposal = factory.fromDelta(delta, proposalId, existingProposal?.metadata, existingProposal?.signatures ?? []);
|
|
486
|
-
|
|
588
|
+
// The outcome lands on the proposal either way; a failure is reported
|
|
589
|
+
// there rather than failing the sync.
|
|
590
|
+
await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
|
|
591
|
+
reported.set(proposal.id, { delta, verified: proposal });
|
|
592
|
+
}
|
|
593
|
+
if (generation !== this.syncGeneration) {
|
|
594
|
+
throw new Error('Sync aborted: the GUARDIAN client was replaced while the sync was in flight');
|
|
595
|
+
}
|
|
596
|
+
const applied = [];
|
|
597
|
+
for (const { delta, verified } of reported.values()) {
|
|
598
|
+
const current = this.proposals.get(verified.id);
|
|
599
|
+
if (current?.status === 'finalized') {
|
|
600
|
+
applied.push(current);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
applied.push(current === undefined
|
|
604
|
+
? verified
|
|
605
|
+
: {
|
|
606
|
+
...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
|
|
607
|
+
verification: verified.verification,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
for (const proposal of applied) {
|
|
487
611
|
this.proposals.set(proposal.id, proposal);
|
|
612
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
613
|
+
}
|
|
614
|
+
const missCounts = new Map();
|
|
615
|
+
for (const [id, snapshot] of candidates) {
|
|
616
|
+
if (reported.has(id) || this.proposals.get(id) !== snapshot) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (this.lastReportedProposalIds.has(id)) {
|
|
620
|
+
this.proposals.delete(id);
|
|
621
|
+
this.guardianKnownProposalIds.delete(id);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (!this.guardianKnownProposalIds.has(id)) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
|
|
628
|
+
if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
|
|
629
|
+
this.proposals.delete(id);
|
|
630
|
+
this.guardianKnownProposalIds.delete(id);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
missCounts.set(id, misses);
|
|
634
|
+
}
|
|
488
635
|
}
|
|
636
|
+
this.unreportedMissCounts = missCounts;
|
|
637
|
+
this.lastReportedProposalIds = new Set(reported.keys());
|
|
489
638
|
return Array.from(this.proposals.values());
|
|
490
639
|
}
|
|
491
640
|
/**
|
|
@@ -539,7 +688,10 @@ export class Multisig {
|
|
|
539
688
|
return { proposals, skipped };
|
|
540
689
|
}
|
|
541
690
|
/**
|
|
542
|
-
*
|
|
691
|
+
* Returns the proposals cached by the most recent {@link syncProposals}
|
|
692
|
+
* call, plus any locally created or imported proposals GUARDIAN has not
|
|
693
|
+
* reported yet (see {@link syncProposals} for their retention). Not a
|
|
694
|
+
* durable history: proposals GUARDIAN no longer reports were pruned.
|
|
543
695
|
*/
|
|
544
696
|
listProposals() {
|
|
545
697
|
return Array.from(this.proposals.values());
|
|
@@ -565,6 +717,7 @@ export class Multisig {
|
|
|
565
717
|
const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
|
|
566
718
|
await this.verifyProposalMetadataBinding(proposal);
|
|
567
719
|
this.proposals.set(proposal.id, proposal);
|
|
720
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
568
721
|
return proposal;
|
|
569
722
|
}
|
|
570
723
|
/**
|
|
@@ -579,8 +732,16 @@ export class Multisig {
|
|
|
579
732
|
const webClient = await this.getRawClient();
|
|
580
733
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
581
734
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
735
|
+
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
736
|
+
// procedure asserts on every transaction after the update, checked before
|
|
737
|
+
// any signature is collected.
|
|
738
|
+
validateMultisigConfig({
|
|
739
|
+
threshold: targetThreshold,
|
|
740
|
+
signerCommitments: targetSignerCommitments,
|
|
741
|
+
guardianCommitment: this.guardianCommitment,
|
|
742
|
+
});
|
|
582
743
|
this.warnOnOverrideDilution(targetSignerCommitments.length);
|
|
583
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments,
|
|
744
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
584
745
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
585
746
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
586
747
|
anchor.free();
|
|
@@ -618,7 +779,7 @@ export class Multisig {
|
|
|
618
779
|
if (targetThreshold < 1 || targetThreshold > targetSignerCommitments.length) {
|
|
619
780
|
throw new Error(`Invalid threshold ${targetThreshold}. Must be between 1 and ${targetSignerCommitments.length}`);
|
|
620
781
|
}
|
|
621
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments,
|
|
782
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, targetThreshold, targetSignerCommitments, this.proposalRequestOptions(options));
|
|
622
783
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
623
784
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
624
785
|
anchor.free();
|
|
@@ -649,7 +810,7 @@ export class Multisig {
|
|
|
649
810
|
if (newThreshold === this.threshold) {
|
|
650
811
|
throw new Error('New threshold is the same as current threshold');
|
|
651
812
|
}
|
|
652
|
-
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments,
|
|
813
|
+
const { request, salt } = await buildUpdateSignersTransactionRequest(webClient, newThreshold, this.signerCommitments, this.proposalRequestOptions(options));
|
|
653
814
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
654
815
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
655
816
|
anchor.free();
|
|
@@ -678,7 +839,7 @@ export class Multisig {
|
|
|
678
839
|
if (currentOverride !== undefined && currentOverride === targetThreshold) {
|
|
679
840
|
throw new Error(`Procedure ${targetProcedure} already has threshold override ${targetThreshold}`);
|
|
680
841
|
}
|
|
681
|
-
const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold,
|
|
842
|
+
const { request, salt } = await buildUpdateProcedureThresholdTransactionRequest(webClient, targetProcedure, targetThreshold, this.proposalRequestOptions(options));
|
|
682
843
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
683
844
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
684
845
|
anchor.free();
|
|
@@ -706,7 +867,7 @@ export class Multisig {
|
|
|
706
867
|
*/
|
|
707
868
|
async createSwitchGuardianProposal(newGuardianEndpoint, newGuardianPubkey, options = {}) {
|
|
708
869
|
const proposalNonce = resolveProposalNonce('createSwitchGuardianProposal', options);
|
|
709
|
-
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
|
|
870
|
+
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
710
871
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
711
872
|
// sign/execute (which fetch from GUARDIAN) can find it. To leave an
|
|
712
873
|
// unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
|
|
@@ -718,10 +879,21 @@ export class Multisig {
|
|
|
718
879
|
* for its summary and metadata. Kept in one place so the online and offline
|
|
719
880
|
* proposals for the same operation can never drift apart.
|
|
720
881
|
*/
|
|
721
|
-
async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey) {
|
|
882
|
+
async buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, approvalExpirationDelta) {
|
|
722
883
|
const webClient = await this.getRawClient();
|
|
884
|
+
// What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
|
|
885
|
+
// before any signature is collected.
|
|
886
|
+
validateMultisigConfig({
|
|
887
|
+
threshold: this.threshold,
|
|
888
|
+
signerCommitments: [...this.signerCommitments],
|
|
889
|
+
guardianCommitment: newGuardianPubkey,
|
|
890
|
+
});
|
|
723
891
|
await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
|
|
724
|
-
const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
|
|
892
|
+
const { request, salt } = await buildUpdateGuardianTransactionRequest(webClient, newGuardianPubkey, {
|
|
893
|
+
accountId: this._accountId,
|
|
894
|
+
approvalExpirationDelta,
|
|
895
|
+
signatureScheme: this.signer.scheme,
|
|
896
|
+
});
|
|
725
897
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
726
898
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
727
899
|
anchor.free();
|
|
@@ -770,7 +942,7 @@ export class Multisig {
|
|
|
770
942
|
// state — or a readiness threshold read from stale config — would only
|
|
771
943
|
// fail at execution, after the whole side-channel cosigning ceremony.
|
|
772
944
|
await this.syncNetworkOnly();
|
|
773
|
-
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey);
|
|
945
|
+
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(newGuardianEndpoint, newGuardianPubkey, options.approvalExpirationDelta);
|
|
774
946
|
const exported = {
|
|
775
947
|
accountId: this._accountId,
|
|
776
948
|
nonce: proposalNonce,
|
|
@@ -808,8 +980,12 @@ export class Multisig {
|
|
|
808
980
|
}
|
|
809
981
|
fetchedNotes.push(inputNoteRecord.toNote());
|
|
810
982
|
}
|
|
983
|
+
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
984
|
+
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
985
|
+
// the notes are authenticated here first, before the anchor is captured.
|
|
986
|
+
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
811
987
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
812
|
-
const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
|
|
988
|
+
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(webClient, fetchedNotes, this.proposalRequestOptions(options));
|
|
813
989
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
814
990
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
815
991
|
anchor.free();
|
|
@@ -854,7 +1030,7 @@ export class Multisig {
|
|
|
854
1030
|
// Forward everything but the nonce, so a note option added to
|
|
855
1031
|
// CreateP2idProposalOptions can't be silently dropped before the builder.
|
|
856
1032
|
const { nonce: _nonce, ...noteOptions } = options;
|
|
857
|
-
const { request, salt } = buildP2idTransactionRequest(this._accountId, recipientId, faucetId, amount, noteOptions);
|
|
1033
|
+
const { request, salt } = await buildP2idTransactionRequest(webClient, this._accountId, recipientId, faucetId, amount, noteOptions);
|
|
858
1034
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
859
1035
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
860
1036
|
anchor.free();
|
|
@@ -1021,6 +1197,17 @@ export class Multisig {
|
|
|
1021
1197
|
* store, reusing this client's Miden RPC endpoint and retry
|
|
1022
1198
|
* configuration.
|
|
1023
1199
|
*/
|
|
1200
|
+
/**
|
|
1201
|
+
* Puts the local store in the canonical (authenticated) consumption mode
|
|
1202
|
+
* for `notes`, fetching missing inclusion proofs from this client's Miden
|
|
1203
|
+
* node; see {@link ensureNotesAuthenticated}.
|
|
1204
|
+
*/
|
|
1205
|
+
async ensureNotesAuthenticated(notes) {
|
|
1206
|
+
await ensureNotesAuthenticated(this.midenClient, notes, {
|
|
1207
|
+
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
1208
|
+
rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1024
1211
|
async importNotesFromProposals(proposals, cancelled) {
|
|
1025
1212
|
return importNotesFromProposalsStandalone(this.midenClient, proposals, {
|
|
1026
1213
|
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
@@ -1294,6 +1481,7 @@ export class Multisig {
|
|
|
1294
1481
|
const signedProposal = factory.fromDelta(signedDelta, normalizedProposalId, proposal.metadata, proposal.signatures);
|
|
1295
1482
|
await this.verifyProposalMetadataBinding(signedProposal);
|
|
1296
1483
|
this.proposals.set(signedProposal.id, signedProposal);
|
|
1484
|
+
this.guardianKnownProposalIds.add(signedProposal.id);
|
|
1297
1485
|
return signedProposal;
|
|
1298
1486
|
}
|
|
1299
1487
|
async getProposalForSigning(proposalId, normalizedProposalId) {
|
|
@@ -1305,7 +1493,8 @@ export class Multisig {
|
|
|
1305
1493
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
1306
1494
|
}
|
|
1307
1495
|
async createTransactionProposalRequest(proposalId) {
|
|
1308
|
-
const { finalRequest } = await this.prepareProposalExecution(proposalId);
|
|
1496
|
+
const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
|
|
1497
|
+
anchor.free();
|
|
1309
1498
|
return finalRequest;
|
|
1310
1499
|
}
|
|
1311
1500
|
/**
|
|
@@ -1314,20 +1503,18 @@ export class Multisig {
|
|
|
1314
1503
|
* @param proposalId - The proposal commitment/ID
|
|
1315
1504
|
*/
|
|
1316
1505
|
async executeProposal(proposalId) {
|
|
1317
|
-
const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
|
|
1318
|
-
if (metadata.proposalType === 'switch_guardian') {
|
|
1319
|
-
// #417: import notes embedded in pending proposals from the old
|
|
1320
|
-
// GUARDIAN. Must run before the switch executes and repoints;
|
|
1321
|
-
// best-effort and bounded — see preservePreSwitchProposalNotes.
|
|
1322
|
-
await this.preservePreSwitchProposalNotes();
|
|
1323
|
-
}
|
|
1324
|
-
// Execute at the proposal's anchored reference block, so the summary the
|
|
1325
|
-
// cosigners signed reproduces exactly. The anchor was already checked
|
|
1326
|
-
// against the summary's block commitment during binding verification.
|
|
1327
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
1328
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1506
|
+
const { metadata, finalRequest, proposal, anchor } = await this.prepareProposalExecution(proposalId);
|
|
1329
1507
|
try {
|
|
1330
|
-
|
|
1508
|
+
if (metadata.proposalType === 'switch_guardian') {
|
|
1509
|
+
// #417: import notes embedded in pending proposals from the old
|
|
1510
|
+
// GUARDIAN. Must run before the switch executes and repoints;
|
|
1511
|
+
// best-effort and bounded — see preservePreSwitchProposalNotes.
|
|
1512
|
+
await this.preservePreSwitchProposalNotes();
|
|
1513
|
+
}
|
|
1514
|
+
// Execute at the proposal's anchored reference block, so the summary the
|
|
1515
|
+
// cosigners signed reproduces exactly. The anchor was already checked
|
|
1516
|
+
// against the summary's block commitment during binding verification.
|
|
1517
|
+
await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
|
|
1331
1518
|
}
|
|
1332
1519
|
finally {
|
|
1333
1520
|
anchor.free();
|
|
@@ -1371,7 +1558,7 @@ export class Multisig {
|
|
|
1371
1558
|
throw new Error(`Transaction executed successfully but failed to register on new GUARDIAN: ${message}`);
|
|
1372
1559
|
}
|
|
1373
1560
|
}
|
|
1374
|
-
proposal.status
|
|
1561
|
+
this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
|
|
1375
1562
|
}
|
|
1376
1563
|
/**
|
|
1377
1564
|
* Submit an integration-built transaction (advice already injected). Mirrors
|
|
@@ -1482,8 +1669,24 @@ export class Multisig {
|
|
|
1482
1669
|
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
1483
1670
|
throw new Error(`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`);
|
|
1484
1671
|
}
|
|
1672
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
1485
1673
|
return this.assembleCustomAdvice(proposalId, signaturesForExecution, signedCommitmentHex, delta);
|
|
1486
1674
|
}
|
|
1675
|
+
buildCosignerAdviceEntry(cosignerSig, signerCommitment, txCommitmentHex) {
|
|
1676
|
+
const approval = cosignerSig.signature;
|
|
1677
|
+
const txCommitment = Word.fromHex(txCommitmentHex);
|
|
1678
|
+
if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
|
|
1679
|
+
if (!approval.publicKey) {
|
|
1680
|
+
throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
|
|
1681
|
+
}
|
|
1682
|
+
return buildEip712SignatureAdviceEntry(signerCommitment, txCommitment, approval.signature, approval.publicKey);
|
|
1683
|
+
}
|
|
1684
|
+
const signature = Signature.deserialize(signatureHexToBytes(approval.signature, approval.scheme));
|
|
1685
|
+
if (approval.scheme === 'ecdsa' && approval.publicKey) {
|
|
1686
|
+
assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
|
|
1687
|
+
}
|
|
1688
|
+
return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
|
|
1689
|
+
}
|
|
1487
1690
|
async assembleCustomAdvice(proposalId, signaturesForExecution, normalizedTxCommitmentHex, delta) {
|
|
1488
1691
|
const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
|
|
1489
1692
|
const adviceMap = new AdviceMap();
|
|
@@ -1505,12 +1708,7 @@ export class Multisig {
|
|
|
1505
1708
|
}
|
|
1506
1709
|
}
|
|
1507
1710
|
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);
|
|
1711
|
+
const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
|
|
1514
1712
|
const keyHex = normalizeHexWord(key.toHex());
|
|
1515
1713
|
if (adviceMapKeys.has(keyHex)) {
|
|
1516
1714
|
throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
|
|
@@ -1554,6 +1752,10 @@ export class Multisig {
|
|
|
1554
1752
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1555
1753
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
1556
1754
|
}
|
|
1755
|
+
/**
|
|
1756
|
+
* The returned `anchor` is the block the request has to execute at; the
|
|
1757
|
+
* caller owns it and frees it once submitted.
|
|
1758
|
+
*/
|
|
1557
1759
|
async prepareProposalExecution(proposalId) {
|
|
1558
1760
|
const proposal = this.getLocalProposal(proposalId);
|
|
1559
1761
|
if (!proposal) {
|
|
@@ -1601,6 +1803,7 @@ export class Multisig {
|
|
|
1601
1803
|
throw new Error(`Proposal ${proposalId} tx_summary commitment ${normalizedTxCommitmentHex} ` +
|
|
1602
1804
|
'does not match the proposal id it belongs to');
|
|
1603
1805
|
}
|
|
1806
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
1604
1807
|
const normalizedSignerCommitments = new Set(this.signerCommitments.map((commitment) => normalizeHexWord(commitment)));
|
|
1605
1808
|
const adviceMap = new AdviceMap();
|
|
1606
1809
|
const adviceMapKeys = new Set();
|
|
@@ -1623,12 +1826,7 @@ export class Multisig {
|
|
|
1623
1826
|
}
|
|
1624
1827
|
}
|
|
1625
1828
|
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);
|
|
1829
|
+
const { key, values } = this.buildCosignerAdviceEntry(cosignerSig, signerCommitment, normalizedTxCommitmentHex);
|
|
1632
1830
|
const keyHex = normalizeHexWord(key.toHex());
|
|
1633
1831
|
if (adviceMapKeys.has(keyHex)) {
|
|
1634
1832
|
throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
|
|
@@ -1674,17 +1872,15 @@ export class Multisig {
|
|
|
1674
1872
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1675
1873
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
1676
1874
|
}
|
|
1677
|
-
|
|
1678
|
-
// ours; without the release it leaks once per execute.
|
|
1679
|
-
const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
|
|
1680
|
-
let finalRequest;
|
|
1875
|
+
const anchor = this.requireProposalAnchor(proposalId, metadata);
|
|
1681
1876
|
try {
|
|
1682
|
-
finalRequest = await this.buildTransactionRequestFromMetadata(metadata,
|
|
1877
|
+
const finalRequest = await this.buildTransactionRequestFromMetadata(metadata, proposalRequestBinding(txSummary, anchor, saltHex), adviceMap);
|
|
1878
|
+
return { finalRequest, metadata, proposal, anchor };
|
|
1683
1879
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1880
|
+
catch (error) {
|
|
1881
|
+
anchor.free();
|
|
1882
|
+
throw error;
|
|
1686
1883
|
}
|
|
1687
|
-
return { finalRequest, metadata, proposal };
|
|
1688
1884
|
}
|
|
1689
1885
|
/**
|
|
1690
1886
|
* Export a proposal for offline signing
|
|
@@ -1699,6 +1895,8 @@ export class Multisig {
|
|
|
1699
1895
|
signatureHex: s.signature.signature,
|
|
1700
1896
|
scheme: s.signature.scheme,
|
|
1701
1897
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
1898
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
1899
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
1702
1900
|
timestamp: s.timestamp,
|
|
1703
1901
|
}))
|
|
1704
1902
|
: [];
|
|
@@ -1732,6 +1930,8 @@ export class Multisig {
|
|
|
1732
1930
|
signatureHex: s.signature.signature,
|
|
1733
1931
|
scheme: s.signature.scheme,
|
|
1734
1932
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
1933
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
1934
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
1735
1935
|
timestamp: s.timestamp,
|
|
1736
1936
|
})),
|
|
1737
1937
|
metadata: proposal.metadata,
|
|
@@ -1795,13 +1995,17 @@ export class Multisig {
|
|
|
1795
1995
|
},
|
|
1796
1996
|
];
|
|
1797
1997
|
const canonicalizedSignatures = new ProposalSignatures(signatures, this.signerCommitments, localSignatureContext).entries();
|
|
1798
|
-
proposal.signatures = canonicalizedSignatures;
|
|
1799
|
-
// Update status
|
|
1800
1998
|
const proposalType = proposal.metadata?.proposalType;
|
|
1801
1999
|
const signaturesRequired = proposalType
|
|
1802
2000
|
? this.getEffectiveThreshold(proposalType)
|
|
1803
2001
|
: this.threshold;
|
|
1804
|
-
|
|
2002
|
+
// A fresh object rather than an in-place write: a sync that snapshotted
|
|
2003
|
+
// the cache before this signature tells the two apart by identity.
|
|
2004
|
+
this.proposals.set(proposal.id, {
|
|
2005
|
+
...proposal,
|
|
2006
|
+
signatures: canonicalizedSignatures,
|
|
2007
|
+
status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
|
|
2008
|
+
});
|
|
1805
2009
|
// Return updated JSON
|
|
1806
2010
|
return this.exportProposalToJson(proposal.id);
|
|
1807
2011
|
}
|
|
@@ -1813,7 +2017,29 @@ export class Multisig {
|
|
|
1813
2017
|
}
|
|
1814
2018
|
return txSummaryCommitment;
|
|
1815
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Verifies that a proposal's metadata reconstructs its signed summary
|
|
2022
|
+
* commitment and records the outcome in {@link Proposal.verification}:
|
|
2023
|
+
* `verified`, or `failed` with the message and whether the failure looked
|
|
2024
|
+
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2025
|
+
* while `syncProposals` keeps going with the outcome recorded.
|
|
2026
|
+
*/
|
|
1816
2027
|
async verifyProposalMetadataBinding(proposal) {
|
|
2028
|
+
try {
|
|
2029
|
+
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2030
|
+
proposal.verification = { status: 'verified' };
|
|
2031
|
+
return commitment;
|
|
2032
|
+
}
|
|
2033
|
+
catch (error) {
|
|
2034
|
+
proposal.verification = {
|
|
2035
|
+
status: 'failed',
|
|
2036
|
+
retryable: isTransientRpcError(error),
|
|
2037
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2038
|
+
};
|
|
2039
|
+
throw error;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
async checkProposalMetadataBinding(proposal) {
|
|
1817
2043
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
1818
2044
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
1819
2045
|
// The anchor arrives from an untrusted party via GUARDIAN, so check its
|
|
@@ -1833,13 +2059,28 @@ export class Multisig {
|
|
|
1833
2059
|
// integrity guarantee for an opaque proposal.
|
|
1834
2060
|
return txSummaryCommitment;
|
|
1835
2061
|
}
|
|
2062
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2063
|
+
// type, switch_guardian included (as in the Rust SDK): a mismatched salt
|
|
2064
|
+
// would otherwise collect signatures and only fail in the VM.
|
|
2065
|
+
const binding = proposalRequestBinding(summary, anchor, this.requireProposalSaltHex(proposal.id, proposal.metadata));
|
|
2066
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2067
|
+
throw new Error(`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`);
|
|
2068
|
+
}
|
|
1836
2069
|
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
1837
|
-
// Re-execution would mutate the WASM account twice. The proposal ID
|
|
1838
|
-
//
|
|
2070
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2071
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2072
|
+
// binding checks for this type.
|
|
1839
2073
|
return txSummaryCommitment;
|
|
1840
2074
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
2075
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2076
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2077
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2078
|
+
// that never held these notes reproduces a different commitment.
|
|
2079
|
+
if (proposal.metadata.proposalType === 'consume_notes' &&
|
|
2080
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
2081
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2082
|
+
}
|
|
2083
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
1843
2084
|
const webClient = await this.getRawClient();
|
|
1844
2085
|
const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
|
|
1845
2086
|
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
@@ -1862,22 +2103,17 @@ export class Multisig {
|
|
|
1862
2103
|
* Reads a proposal's salt. Throws when absent, because there is nothing to fall
|
|
1863
2104
|
* back to.
|
|
1864
2105
|
*
|
|
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.
|
|
2106
|
+
* The salt goes into the request's multisig auth args and the summary binds it in
|
|
2107
|
+
* its user params, so `summarySalt(summary)` reads the value the cosigners signed
|
|
2108
|
+
* over. It is not a substitute for this field: a request has to be rebuilt before
|
|
2109
|
+
* any summary exists, and a proposal GUARDIAN serves may pair a summary with
|
|
2110
|
+
* metadata that names another salt, which the binding check reports by name.
|
|
1874
2111
|
*/
|
|
1875
2112
|
requireProposalSaltHex(proposalId, metadata) {
|
|
1876
2113
|
const saltHex = metadata.saltHex;
|
|
1877
2114
|
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');
|
|
2115
|
+
throw new Error(`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
|
|
2116
|
+
'salt its auth args and signed summary bind');
|
|
1881
2117
|
}
|
|
1882
2118
|
// GUARDIAN serves this field and the response is cast, not parsed, so everything
|
|
1883
2119
|
// below is untrusted input. A truthiness test is not enough: `normalizeHexWord`
|
|
@@ -1912,46 +2148,68 @@ export class Multisig {
|
|
|
1912
2148
|
}
|
|
1913
2149
|
return chainAnchorFromBase64(metadata.chainAnchor);
|
|
1914
2150
|
}
|
|
1915
|
-
|
|
2151
|
+
/**
|
|
2152
|
+
* An expired approval aborts in the auth procedure only at execution.
|
|
2153
|
+
* The summary carries the deadline, so callers check it against the sync
|
|
2154
|
+
* height before assembling advice or requesting the GUARDIAN ack.
|
|
2155
|
+
*/
|
|
2156
|
+
async assertApprovalNotExpired(proposalId, summary) {
|
|
2157
|
+
const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
|
|
2158
|
+
if (expirationBlockNum === undefined) {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const webClient = await this.getRawClient();
|
|
2162
|
+
const syncHeight = await webClient.getSyncHeight();
|
|
2163
|
+
if (syncHeight >= expirationBlockNum) {
|
|
2164
|
+
throw new Error(`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
|
|
2165
|
+
`block ${syncHeight}, so the collected signatures no longer authorize it`);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Rebuilds a proposal's request from its metadata under `binding`, so the
|
|
2170
|
+
* summary it produces is the one the cosigners signed.
|
|
2171
|
+
*/
|
|
2172
|
+
async buildTransactionRequestFromMetadata(metadata, binding, signatureAdviceMap) {
|
|
2173
|
+
// The builders read `.toHex()` and allocate their own Word, so this handle
|
|
2174
|
+
// stays ours; without the release it leaks once per rebuild.
|
|
2175
|
+
const salt = Word.fromHex(binding.saltHex);
|
|
2176
|
+
try {
|
|
2177
|
+
return await this.buildTransactionRequestWithOptions(metadata, {
|
|
2178
|
+
accountId: this._accountId,
|
|
2179
|
+
boundBlockNum: binding.boundBlockNum,
|
|
2180
|
+
approvalExpirationDelta: binding.approvalExpirationDelta,
|
|
2181
|
+
salt,
|
|
2182
|
+
signatureAdviceMap,
|
|
2183
|
+
signatureScheme: this.signer.scheme,
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
finally {
|
|
2187
|
+
salt.free?.();
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
async buildTransactionRequestWithOptions(metadata, requestOptions) {
|
|
1916
2191
|
const webClient = await this.getRawClient();
|
|
1917
2192
|
switch (metadata.proposalType) {
|
|
1918
2193
|
case 'add_signer':
|
|
1919
2194
|
case 'remove_signer':
|
|
1920
2195
|
case 'change_threshold': {
|
|
1921
|
-
const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments,
|
|
2196
|
+
const { request } = await buildUpdateSignersTransactionRequest(webClient, metadata.targetThreshold, metadata.targetSignerCommitments, requestOptions);
|
|
1922
2197
|
return request;
|
|
1923
2198
|
}
|
|
1924
2199
|
case 'switch_guardian': {
|
|
1925
|
-
const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey,
|
|
2200
|
+
const { request } = await buildUpdateGuardianTransactionRequest(webClient, metadata.newGuardianPubkey, requestOptions);
|
|
1926
2201
|
return request;
|
|
1927
2202
|
}
|
|
1928
2203
|
case 'update_procedure_threshold': {
|
|
1929
|
-
const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold,
|
|
2204
|
+
const { request } = await buildUpdateProcedureThresholdTransactionRequest(webClient, metadata.targetProcedure, metadata.targetThreshold, requestOptions);
|
|
1930
2205
|
return request;
|
|
1931
2206
|
}
|
|
1932
2207
|
case 'consume_notes': {
|
|
1933
2208
|
// v1/v2 dispatch for issue #229 / FR-009.
|
|
1934
2209
|
const version = metadata.metadataVersion;
|
|
1935
2210
|
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
|
-
});
|
|
2211
|
+
const decoded = decodeEmbeddedConsumeNotes(metadata);
|
|
2212
|
+
const { request } = await buildConsumeNotesTransactionRequestFromNotes(webClient, decoded, requestOptions);
|
|
1955
2213
|
return request;
|
|
1956
2214
|
}
|
|
1957
2215
|
if (version === undefined || version === 1) {
|
|
@@ -1960,15 +2218,14 @@ export class Multisig {
|
|
|
1960
2218
|
// operator which legacy shape was rejected.
|
|
1961
2219
|
throw new UnsupportedMetadataVersionError(version);
|
|
1962
2220
|
}
|
|
1963
|
-
const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds,
|
|
2221
|
+
const { request } = await buildConsumeNotesTransactionRequest(webClient, metadata.noteIds, requestOptions);
|
|
1964
2222
|
return request;
|
|
1965
2223
|
}
|
|
1966
2224
|
throw new UnsupportedMetadataVersionError(version);
|
|
1967
2225
|
}
|
|
1968
2226
|
case 'p2id': {
|
|
1969
|
-
const { request } = buildP2idTransactionRequest(this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
|
|
1970
|
-
|
|
1971
|
-
signatureAdviceMap,
|
|
2227
|
+
const { request } = await buildP2idTransactionRequest(webClient, this._accountId, metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), {
|
|
2228
|
+
...requestOptions,
|
|
1972
2229
|
noteType: parseP2idNoteType(metadata.noteType),
|
|
1973
2230
|
reclaimHeight: metadata.reclaimHeight,
|
|
1974
2231
|
timelockHeight: metadata.timelockHeight,
|
|
@@ -1980,4 +2237,27 @@ export class Multisig {
|
|
|
1980
2237
|
}
|
|
1981
2238
|
}
|
|
1982
2239
|
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
|
|
2242
|
+
* is the note its declared id names (spec 006 FR-007).
|
|
2243
|
+
*/
|
|
2244
|
+
function decodeEmbeddedConsumeNotes(metadata) {
|
|
2245
|
+
const embedded = metadata.notes ?? [];
|
|
2246
|
+
const noteIds = metadata.noteIds ?? [];
|
|
2247
|
+
if (embedded.length !== noteIds.length) {
|
|
2248
|
+
throw new NoteBindingMismatchError(`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`);
|
|
2249
|
+
}
|
|
2250
|
+
const decoded = [];
|
|
2251
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
2252
|
+
const note = noteFromBase64(embedded[i], Note);
|
|
2253
|
+
// Normalize both sides; matches the file's other hex comparisons.
|
|
2254
|
+
const embeddedId = normalizeHexWord(note.id().toString());
|
|
2255
|
+
const declaredId = normalizeHexWord(noteIds[i]);
|
|
2256
|
+
if (embeddedId !== declaredId) {
|
|
2257
|
+
throw new NoteBindingMismatchError(`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`);
|
|
2258
|
+
}
|
|
2259
|
+
decoded.push(note);
|
|
2260
|
+
}
|
|
2261
|
+
return decoded;
|
|
2262
|
+
}
|
|
1983
2263
|
//# sourceMappingURL=multisig.js.map
|