@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/src/multisig.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
AccountId,
|
|
28
28
|
AdviceMap,
|
|
29
29
|
Endpoint,
|
|
30
|
+
type Felt,
|
|
30
31
|
FeltArray,
|
|
31
32
|
Note,
|
|
32
33
|
NoteExportFormat,
|
|
@@ -44,6 +45,8 @@ import {
|
|
|
44
45
|
chainAnchorToBase64,
|
|
45
46
|
executeForSummary,
|
|
46
47
|
executeForSummaryAt,
|
|
48
|
+
summaryApprovalExpirationBlockNum,
|
|
49
|
+
summarySalt,
|
|
47
50
|
buildUpdateSignersTransactionRequest,
|
|
48
51
|
buildUpdateProcedureThresholdTransactionRequest,
|
|
49
52
|
buildUpdateGuardianTransactionRequest,
|
|
@@ -55,9 +58,13 @@ import {
|
|
|
55
58
|
type P2ideHeightOptions,
|
|
56
59
|
} from './transaction.js';
|
|
57
60
|
import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
|
|
61
|
+
import type { MultisigRequestOptions } from './transaction/options.js';
|
|
62
|
+
import { validateMultisigConfig } from './account/builder.js';
|
|
63
|
+
import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
|
|
58
64
|
import {
|
|
59
65
|
CONSUME_NOTES_METADATA_VERSION_V2,
|
|
60
66
|
MAX_CONSUME_NOTES_METADATA_BYTES,
|
|
67
|
+
type ConsumeNotesProposalMetadata,
|
|
61
68
|
} from './types/proposal.js';
|
|
62
69
|
import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
|
|
63
70
|
import {
|
|
@@ -74,6 +81,7 @@ import {
|
|
|
74
81
|
} from './utils/encoding.js';
|
|
75
82
|
import {
|
|
76
83
|
assertEcdsaSignatureRecoverable,
|
|
84
|
+
buildEip712SignatureAdviceEntry,
|
|
77
85
|
buildSignatureAdviceEntry,
|
|
78
86
|
normalizeSignerCommitment,
|
|
79
87
|
signatureHexToBytes,
|
|
@@ -116,7 +124,9 @@ import {
|
|
|
116
124
|
resolveRpcConfig,
|
|
117
125
|
type ResolvedRpcConfig,
|
|
118
126
|
} from './rpc/config.js';
|
|
127
|
+
import { isTransientRpcError } from './rpc/errors.js';
|
|
119
128
|
import { retryRpcRead } from './rpc/retry.js';
|
|
129
|
+
import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
|
|
120
130
|
|
|
121
131
|
/**
|
|
122
132
|
* Result of fetching account state from GUARDIAN.
|
|
@@ -146,6 +156,13 @@ export interface AccountStateVerificationResult {
|
|
|
146
156
|
export interface CreateProposalOptions {
|
|
147
157
|
/** Proposal nonce; defaults to `Date.now()`. */
|
|
148
158
|
nonce?: number;
|
|
159
|
+
/**
|
|
160
|
+
* Blocks after the proposal's anchor block by which the transaction must be
|
|
161
|
+
* included; past that the approvers' signatures no longer authorize it. The
|
|
162
|
+
* summary binds it, so the executing party can neither shorten nor extend it.
|
|
163
|
+
* Omitted, the approval never expires (the upstream default).
|
|
164
|
+
*/
|
|
165
|
+
approvalExpirationDelta?: number;
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
export interface CreateSignerProposalOptions extends CreateProposalOptions {
|
|
@@ -211,6 +228,64 @@ function resolveProposalNonce(
|
|
|
211
228
|
return options.nonce ?? Date.now();
|
|
212
229
|
}
|
|
213
230
|
|
|
231
|
+
/**
|
|
232
|
+
* What a rebuild of a proposal's request pins so the signed summary
|
|
233
|
+
* reproduces: the salt, the block its anchor names, and the approval
|
|
234
|
+
* expiration the summary binds, as the delta the builders take.
|
|
235
|
+
*/
|
|
236
|
+
interface ProposalRequestBinding {
|
|
237
|
+
saltHex: string;
|
|
238
|
+
boundBlockNum: number;
|
|
239
|
+
approvalExpirationDelta: number | undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function proposalRequestBinding(
|
|
243
|
+
summary: TransactionSummary,
|
|
244
|
+
anchor: ChainAnchor,
|
|
245
|
+
saltHex: string,
|
|
246
|
+
): ProposalRequestBinding {
|
|
247
|
+
const boundBlockNum = anchor.blockNum();
|
|
248
|
+
return {
|
|
249
|
+
saltHex: normalizeHexWord(saltHex),
|
|
250
|
+
boundBlockNum,
|
|
251
|
+
approvalExpirationDelta: approvalExpirationDeltaOf(
|
|
252
|
+
summaryApprovalExpirationBlockNum(summary),
|
|
253
|
+
boundBlockNum,
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The approval expiration delta a rebuild has to pass: the absolute expiration
|
|
260
|
+
* block the summary binds, relative to the bound block. `undefined` for an
|
|
261
|
+
* approval that never expires.
|
|
262
|
+
*/
|
|
263
|
+
function approvalExpirationDeltaOf(
|
|
264
|
+
expirationBlockNum: number | undefined,
|
|
265
|
+
boundBlockNum: number,
|
|
266
|
+
): number | undefined {
|
|
267
|
+
if (expirationBlockNum === undefined) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const delta = expirationBlockNum - boundBlockNum;
|
|
271
|
+
if (delta < 1) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
|
|
274
|
+
`block ${boundBlockNum} its summary binds`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return delta;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function summarySaltHex(summary: TransactionSummary): string {
|
|
281
|
+
const salt = summarySalt(summary);
|
|
282
|
+
try {
|
|
283
|
+
return normalizeHexWord(salt.toHex());
|
|
284
|
+
} finally {
|
|
285
|
+
salt.free?.();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
214
289
|
/**
|
|
215
290
|
* Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
|
|
216
291
|
* stack applies request deadlines, and a half-dead old GUARDIAN must not
|
|
@@ -231,6 +306,13 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
|
|
|
231
306
|
/** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
|
|
232
307
|
const MAX_SALT_HEX_DIGITS = 64;
|
|
233
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Consecutive successful listings that must omit a guardian-known proposal
|
|
311
|
+
* not yet listed (a fresh create during read-your-writes lag, or one
|
|
312
|
+
* orphaned by a GUARDIAN repoint) before the sync prunes it.
|
|
313
|
+
*/
|
|
314
|
+
const UNREPORTED_LISTING_MISS_LIMIT = 2;
|
|
315
|
+
|
|
234
316
|
export class Multisig {
|
|
235
317
|
account: Account;
|
|
236
318
|
threshold: number;
|
|
@@ -248,6 +330,24 @@ export class Multisig {
|
|
|
248
330
|
private readonly _accountId: string;
|
|
249
331
|
private readonly midenRpcEndpoint: string;
|
|
250
332
|
private proposals: Map<string, Proposal> = new Map();
|
|
333
|
+
/** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
|
|
334
|
+
private lastReportedProposalIds: Set<string> = new Set();
|
|
335
|
+
/**
|
|
336
|
+
* Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
|
|
337
|
+
* acknowledged `signProposal` signatures, plus every listed id. Only these
|
|
338
|
+
* are subject to miss-based pruning; offline creations and imports GUARDIAN
|
|
339
|
+
* never received are exempt.
|
|
340
|
+
*/
|
|
341
|
+
private guardianKnownProposalIds: Set<string> = new Set();
|
|
342
|
+
/**
|
|
343
|
+
* Consecutive successful listings that omitted a guardian-known proposal
|
|
344
|
+
* not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
|
|
345
|
+
*/
|
|
346
|
+
private unreportedMissCounts: Map<string, number> = new Map();
|
|
347
|
+
/** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
|
|
348
|
+
private syncGeneration = 0;
|
|
349
|
+
/** Pending sync shared by overlapping {@link syncProposals} callers. */
|
|
350
|
+
private syncProposalsInFlight?: Promise<Proposal[]>;
|
|
251
351
|
|
|
252
352
|
constructor(
|
|
253
353
|
account: Account,
|
|
@@ -435,6 +535,20 @@ export class Multisig {
|
|
|
435
535
|
}));
|
|
436
536
|
}
|
|
437
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The request options every `create*Proposal` hands its builder: the account,
|
|
540
|
+
* the caller's approval expiration, and the signer's scheme.
|
|
541
|
+
*/
|
|
542
|
+
private proposalRequestOptions(options: {
|
|
543
|
+
approvalExpirationDelta?: number;
|
|
544
|
+
}): Pick<MultisigRequestOptions, 'accountId' | 'approvalExpirationDelta' | 'signatureScheme'> {
|
|
545
|
+
return {
|
|
546
|
+
accountId: this._accountId,
|
|
547
|
+
approvalExpirationDelta: options.approvalExpirationDelta,
|
|
548
|
+
signatureScheme: this.signer.scheme,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
438
552
|
private warnOnOverrideDilution(newNumSigners: number): void {
|
|
439
553
|
const current = this.signerCommitments.length;
|
|
440
554
|
for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
|
|
@@ -454,11 +568,23 @@ export class Multisig {
|
|
|
454
568
|
* survive a switch, and the notes embedded in them can only be imported
|
|
455
569
|
* while the old GUARDIAN is still the current client.
|
|
456
570
|
*
|
|
571
|
+
* Repointing abandons a {@link syncProposals} still in flight: it rejects
|
|
572
|
+
* without applying its listing, though its request to the old GUARDIAN is
|
|
573
|
+
* not cancelled, so callers already awaiting it see the rejection only
|
|
574
|
+
* once that response settles. The reported-id and miss-count bookkeeping
|
|
575
|
+
* is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
|
|
576
|
+
* so proposals orphaned by the repoint expire through the two-miss rule
|
|
577
|
+
* instead of lingering.
|
|
578
|
+
*
|
|
457
579
|
* @param guardianClient - The new GUARDIAN HTTP client
|
|
458
580
|
*/
|
|
459
581
|
setGuardianClient(guardianClient: GuardianHttpClient): void {
|
|
460
582
|
this.guardian = guardianClient;
|
|
461
583
|
this.guardian.setSigner(this.signer);
|
|
584
|
+
this.syncGeneration += 1;
|
|
585
|
+
this.syncProposalsInFlight = undefined;
|
|
586
|
+
this.lastReportedProposalIds = new Set();
|
|
587
|
+
this.unreportedMissCounts = new Map();
|
|
462
588
|
}
|
|
463
589
|
|
|
464
590
|
/**
|
|
@@ -573,66 +699,16 @@ export class Multisig {
|
|
|
573
699
|
incomingAccount: Account,
|
|
574
700
|
localAccount?: Account,
|
|
575
701
|
): Promise<boolean> {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (incomingNonce === localNonce) {
|
|
585
|
-
throw new Error(
|
|
586
|
-
`Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
592
|
-
const onChainCommitment = await this.getOnChainCommitment(accountId);
|
|
593
|
-
if (!onChainCommitment) {
|
|
594
|
-
return true;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
|
|
598
|
-
if (incomingCommitment !== onChainCommitment) {
|
|
599
|
-
throw new Error(
|
|
600
|
-
`Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
return true;
|
|
702
|
+
return isSafeToAdoptGuardianState({
|
|
703
|
+
accountId: this._accountId,
|
|
704
|
+
incomingAccount,
|
|
705
|
+
localAccount,
|
|
706
|
+
readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
|
|
707
|
+
});
|
|
605
708
|
}
|
|
606
709
|
|
|
607
710
|
private async getOnChainCommitment(accountId: AccountId): Promise<string | null> {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
const accountDetails = await retryRpcRead(
|
|
612
|
-
() => rpcClient.getAccountDetails(accountId),
|
|
613
|
-
this.rpcConfig,
|
|
614
|
-
);
|
|
615
|
-
// If the account is not found or its commitment is zero, means that the account is not deployed yet
|
|
616
|
-
if (!accountDetails) {
|
|
617
|
-
return null;
|
|
618
|
-
}
|
|
619
|
-
const commitment = normalizeHexWord(accountDetails.commitment().toHex());
|
|
620
|
-
const zeroCommitment = `0x${'0'.repeat(64)}`;
|
|
621
|
-
if (commitment === zeroCommitment) {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
return commitment;
|
|
625
|
-
} catch (error) {
|
|
626
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
627
|
-
if (
|
|
628
|
-
message.includes('null pointer passed to rust') ||
|
|
629
|
-
message.includes('No account header record found for given ID') ||
|
|
630
|
-
message.toLowerCase().includes('not found')
|
|
631
|
-
) {
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
throw error;
|
|
635
|
-
}
|
|
711
|
+
return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
|
|
636
712
|
}
|
|
637
713
|
|
|
638
714
|
/**
|
|
@@ -715,12 +791,67 @@ export class Multisig {
|
|
|
715
791
|
}
|
|
716
792
|
|
|
717
793
|
/**
|
|
718
|
-
* Sync proposals from the GUARDIAN server
|
|
794
|
+
* Sync proposals from the GUARDIAN server, reconciling the local cache to
|
|
795
|
+
* the response. GUARDIAN reports only pending proposals, so a proposal it
|
|
796
|
+
* reported on an earlier sync and now omits is pruned immediately. A
|
|
797
|
+
* proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
|
|
798
|
+
* its read-your-writes has not caught up with, or a proposal orphaned by a
|
|
799
|
+
* {@link setGuardianClient} repoint) is pruned only after
|
|
800
|
+
* {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
|
|
801
|
+
* Proposals GUARDIAN never received (an `importProposal`, or a
|
|
802
|
+
* `createSwitchGuardianProposalOffline`) are not pruned by listings; an
|
|
803
|
+
* import graduates to the pruned classes once GUARDIAN acknowledges it
|
|
804
|
+
* (listed, or a successful online `signProposal`).
|
|
805
|
+
* Proposals cached or replaced after the sync started are not evaluated
|
|
806
|
+
* by it.
|
|
807
|
+
*
|
|
808
|
+
* Every synced proposal's metadata is checked against its signed summary
|
|
809
|
+
* and the outcome is recorded in {@link Proposal.verification}. One that
|
|
810
|
+
* fails is still cached and returned, so a single stale or corrupt
|
|
811
|
+
* proposal cannot hide the others (issue #462: once the node prunes a
|
|
812
|
+
* proposal's anchor block its re-execution fails for everyone). `failed`
|
|
813
|
+
* with `retryable: true` means a transient node error, worth syncing
|
|
814
|
+
* again; `retryable: false` means the proposal cannot be reproduced and
|
|
815
|
+
* must be re-proposed. `signProposal` and `executeProposal` re-verify and
|
|
816
|
+
* refuse a failed proposal. A failed proposal still counts as reported, so
|
|
817
|
+
* it is pruned like any other once GUARDIAN stops listing it.
|
|
818
|
+
*
|
|
819
|
+
* The response is parsed in full before the cache or the pruning state
|
|
820
|
+
* changes; a payload that does not parse at all rejects and leaves both
|
|
821
|
+
* untouched, so malformed GUARDIAN data is never silently dropped.
|
|
822
|
+
* Signatures added to a cached proposal while the sync was verifying are
|
|
823
|
+
* preserved by its apply, and a proposal executed locally in that window
|
|
824
|
+
* stays `finalized` rather than reverting to the listed pending state.
|
|
825
|
+
* Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
|
|
826
|
+
* without applying its listing.
|
|
827
|
+
*
|
|
828
|
+
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
829
|
+
* `filterVisibleProposals`): callers of this shared client disagree on
|
|
830
|
+
* whether a proposal's `nonce` is the pre-execution or the next account
|
|
831
|
+
* nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
|
|
832
|
+
* cannot be applied here. This is an intentional TS/Rust surface
|
|
833
|
+
* difference.
|
|
719
834
|
*/
|
|
720
|
-
|
|
835
|
+
syncProposals(): Promise<Proposal[]> {
|
|
836
|
+
if (this.syncProposalsInFlight) {
|
|
837
|
+
return this.syncProposalsInFlight;
|
|
838
|
+
}
|
|
839
|
+
const inFlight = this.reconcileProposals().finally(() => {
|
|
840
|
+
if (this.syncProposalsInFlight === inFlight) {
|
|
841
|
+
this.syncProposalsInFlight = undefined;
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
this.syncProposalsInFlight = inFlight;
|
|
845
|
+
return inFlight;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private async reconcileProposals(): Promise<Proposal[]> {
|
|
849
|
+
const generation = this.syncGeneration;
|
|
850
|
+
const candidates = new Map(this.proposals);
|
|
721
851
|
const deltas = await this.guardian.getDeltaProposals(this._accountId);
|
|
722
852
|
const factory = this.proposalFactory();
|
|
723
853
|
|
|
854
|
+
const reported = new Map<string, { delta: (typeof deltas)[number]; verified: Proposal }>();
|
|
724
855
|
for (const delta of deltas) {
|
|
725
856
|
const proposalId = normalizeHexWord(
|
|
726
857
|
computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
|
|
@@ -732,10 +863,62 @@ export class Multisig {
|
|
|
732
863
|
existingProposal?.metadata,
|
|
733
864
|
existingProposal?.signatures ?? [],
|
|
734
865
|
);
|
|
735
|
-
|
|
866
|
+
// The outcome lands on the proposal either way; a failure is reported
|
|
867
|
+
// there rather than failing the sync.
|
|
868
|
+
await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
|
|
869
|
+
reported.set(proposal.id, { delta, verified: proposal });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (generation !== this.syncGeneration) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
'Sync aborted: the GUARDIAN client was replaced while the sync was in flight'
|
|
875
|
+
);
|
|
876
|
+
}
|
|
736
877
|
|
|
878
|
+
const applied: Proposal[] = [];
|
|
879
|
+
for (const { delta, verified } of reported.values()) {
|
|
880
|
+
const current = this.proposals.get(verified.id);
|
|
881
|
+
if (current?.status === 'finalized') {
|
|
882
|
+
applied.push(current);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
applied.push(
|
|
886
|
+
current === undefined
|
|
887
|
+
? verified
|
|
888
|
+
: {
|
|
889
|
+
...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
|
|
890
|
+
verification: verified.verification,
|
|
891
|
+
}
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
for (const proposal of applied) {
|
|
737
895
|
this.proposals.set(proposal.id, proposal);
|
|
896
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const missCounts = new Map<string, number>();
|
|
900
|
+
for (const [id, snapshot] of candidates) {
|
|
901
|
+
if (reported.has(id) || this.proposals.get(id) !== snapshot) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (this.lastReportedProposalIds.has(id)) {
|
|
905
|
+
this.proposals.delete(id);
|
|
906
|
+
this.guardianKnownProposalIds.delete(id);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (!this.guardianKnownProposalIds.has(id)) {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
|
|
913
|
+
if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
|
|
914
|
+
this.proposals.delete(id);
|
|
915
|
+
this.guardianKnownProposalIds.delete(id);
|
|
916
|
+
} else {
|
|
917
|
+
missCounts.set(id, misses);
|
|
918
|
+
}
|
|
738
919
|
}
|
|
920
|
+
this.unreportedMissCounts = missCounts;
|
|
921
|
+
this.lastReportedProposalIds = new Set(reported.keys());
|
|
739
922
|
|
|
740
923
|
return Array.from(this.proposals.values());
|
|
741
924
|
}
|
|
@@ -802,7 +985,10 @@ export class Multisig {
|
|
|
802
985
|
}
|
|
803
986
|
|
|
804
987
|
/**
|
|
805
|
-
*
|
|
988
|
+
* Returns the proposals cached by the most recent {@link syncProposals}
|
|
989
|
+
* call, plus any locally created or imported proposals GUARDIAN has not
|
|
990
|
+
* reported yet (see {@link syncProposals} for their retention). Not a
|
|
991
|
+
* durable history: proposals GUARDIAN no longer reports were pruned.
|
|
806
992
|
*/
|
|
807
993
|
listProposals(): Proposal[] {
|
|
808
994
|
return Array.from(this.proposals.values());
|
|
@@ -831,6 +1017,7 @@ export class Multisig {
|
|
|
831
1017
|
const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
|
|
832
1018
|
await this.verifyProposalMetadataBinding(proposal);
|
|
833
1019
|
this.proposals.set(proposal.id, proposal);
|
|
1020
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
834
1021
|
|
|
835
1022
|
return proposal;
|
|
836
1023
|
}
|
|
@@ -851,13 +1038,21 @@ export class Multisig {
|
|
|
851
1038
|
const webClient = await this.getRawClient();
|
|
852
1039
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
853
1040
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
1041
|
+
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
1042
|
+
// procedure asserts on every transaction after the update, checked before
|
|
1043
|
+
// any signature is collected.
|
|
1044
|
+
validateMultisigConfig({
|
|
1045
|
+
threshold: targetThreshold,
|
|
1046
|
+
signerCommitments: targetSignerCommitments,
|
|
1047
|
+
guardianCommitment: this.guardianCommitment,
|
|
1048
|
+
});
|
|
854
1049
|
this.warnOnOverrideDilution(targetSignerCommitments.length);
|
|
855
1050
|
|
|
856
1051
|
const { request, salt } = await buildUpdateSignersTransactionRequest(
|
|
857
1052
|
webClient,
|
|
858
1053
|
targetThreshold,
|
|
859
1054
|
targetSignerCommitments,
|
|
860
|
-
|
|
1055
|
+
this.proposalRequestOptions(options),
|
|
861
1056
|
);
|
|
862
1057
|
|
|
863
1058
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -916,7 +1111,7 @@ export class Multisig {
|
|
|
916
1111
|
webClient,
|
|
917
1112
|
targetThreshold,
|
|
918
1113
|
targetSignerCommitments,
|
|
919
|
-
|
|
1114
|
+
this.proposalRequestOptions(options),
|
|
920
1115
|
);
|
|
921
1116
|
|
|
922
1117
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -963,7 +1158,7 @@ export class Multisig {
|
|
|
963
1158
|
webClient,
|
|
964
1159
|
newThreshold,
|
|
965
1160
|
this.signerCommitments,
|
|
966
|
-
|
|
1161
|
+
this.proposalRequestOptions(options),
|
|
967
1162
|
);
|
|
968
1163
|
|
|
969
1164
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1012,7 +1207,7 @@ export class Multisig {
|
|
|
1012
1207
|
webClient,
|
|
1013
1208
|
targetProcedure,
|
|
1014
1209
|
targetThreshold,
|
|
1015
|
-
|
|
1210
|
+
this.proposalRequestOptions(options),
|
|
1016
1211
|
);
|
|
1017
1212
|
|
|
1018
1213
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1052,6 +1247,7 @@ export class Multisig {
|
|
|
1052
1247
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1053
1248
|
newGuardianEndpoint,
|
|
1054
1249
|
newGuardianPubkey,
|
|
1250
|
+
options.approvalExpirationDelta,
|
|
1055
1251
|
);
|
|
1056
1252
|
|
|
1057
1253
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
@@ -1069,14 +1265,26 @@ export class Multisig {
|
|
|
1069
1265
|
private async buildSwitchGuardianSummary(
|
|
1070
1266
|
newGuardianEndpoint: string,
|
|
1071
1267
|
newGuardianPubkey: string,
|
|
1268
|
+
approvalExpirationDelta: number | undefined,
|
|
1072
1269
|
): Promise<{ summaryBase64: string; metadata: ProposalMetadata }> {
|
|
1073
1270
|
const webClient = await this.getRawClient();
|
|
1271
|
+
// What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
|
|
1272
|
+
// before any signature is collected.
|
|
1273
|
+
validateMultisigConfig({
|
|
1274
|
+
threshold: this.threshold,
|
|
1275
|
+
signerCommitments: [...this.signerCommitments],
|
|
1276
|
+
guardianCommitment: newGuardianPubkey,
|
|
1277
|
+
});
|
|
1074
1278
|
await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
|
|
1075
1279
|
|
|
1076
1280
|
const { request, salt } = await buildUpdateGuardianTransactionRequest(
|
|
1077
1281
|
webClient,
|
|
1078
1282
|
newGuardianPubkey,
|
|
1079
|
-
{
|
|
1283
|
+
{
|
|
1284
|
+
accountId: this._accountId,
|
|
1285
|
+
approvalExpirationDelta,
|
|
1286
|
+
signatureScheme: this.signer.scheme,
|
|
1287
|
+
},
|
|
1080
1288
|
);
|
|
1081
1289
|
|
|
1082
1290
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1139,6 +1347,7 @@ export class Multisig {
|
|
|
1139
1347
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1140
1348
|
newGuardianEndpoint,
|
|
1141
1349
|
newGuardianPubkey,
|
|
1350
|
+
options.approvalExpirationDelta,
|
|
1142
1351
|
);
|
|
1143
1352
|
|
|
1144
1353
|
const exported: ExportedProposal = {
|
|
@@ -1184,9 +1393,17 @@ export class Multisig {
|
|
|
1184
1393
|
}
|
|
1185
1394
|
fetchedNotes.push(inputNoteRecord.toNote());
|
|
1186
1395
|
}
|
|
1396
|
+
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
1397
|
+
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
1398
|
+
// the notes are authenticated here first, before the anchor is captured.
|
|
1399
|
+
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
1187
1400
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
1188
1401
|
|
|
1189
|
-
const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(
|
|
1402
|
+
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
|
|
1403
|
+
webClient,
|
|
1404
|
+
fetchedNotes,
|
|
1405
|
+
this.proposalRequestOptions(options),
|
|
1406
|
+
);
|
|
1190
1407
|
|
|
1191
1408
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
1192
1409
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
@@ -1243,7 +1460,8 @@ export class Multisig {
|
|
|
1243
1460
|
// Forward everything but the nonce, so a note option added to
|
|
1244
1461
|
// CreateP2idProposalOptions can't be silently dropped before the builder.
|
|
1245
1462
|
const { nonce: _nonce, ...noteOptions } = options;
|
|
1246
|
-
const { request, salt } = buildP2idTransactionRequest(
|
|
1463
|
+
const { request, salt } = await buildP2idTransactionRequest(
|
|
1464
|
+
webClient,
|
|
1247
1465
|
this._accountId,
|
|
1248
1466
|
recipientId,
|
|
1249
1467
|
faucetId,
|
|
@@ -1441,6 +1659,18 @@ export class Multisig {
|
|
|
1441
1659
|
* store, reusing this client's Miden RPC endpoint and retry
|
|
1442
1660
|
* configuration.
|
|
1443
1661
|
*/
|
|
1662
|
+
/**
|
|
1663
|
+
* Puts the local store in the canonical (authenticated) consumption mode
|
|
1664
|
+
* for `notes`, fetching missing inclusion proofs from this client's Miden
|
|
1665
|
+
* node; see {@link ensureNotesAuthenticated}.
|
|
1666
|
+
*/
|
|
1667
|
+
private async ensureNotesAuthenticated(notes: readonly Note[]): Promise<void> {
|
|
1668
|
+
await ensureNotesAuthenticated(this.midenClient, notes, {
|
|
1669
|
+
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
1670
|
+
rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1444
1674
|
private async importNotesFromProposals(
|
|
1445
1675
|
proposals: ReadonlyArray<Pick<Proposal, 'id' | 'metadata'>>,
|
|
1446
1676
|
cancelled?: () => boolean,
|
|
@@ -1771,6 +2001,7 @@ export class Multisig {
|
|
|
1771
2001
|
await this.verifyProposalMetadataBinding(signedProposal);
|
|
1772
2002
|
|
|
1773
2003
|
this.proposals.set(signedProposal.id, signedProposal);
|
|
2004
|
+
this.guardianKnownProposalIds.add(signedProposal.id);
|
|
1774
2005
|
|
|
1775
2006
|
return signedProposal;
|
|
1776
2007
|
}
|
|
@@ -1789,7 +2020,8 @@ export class Multisig {
|
|
|
1789
2020
|
}
|
|
1790
2021
|
|
|
1791
2022
|
async createTransactionProposalRequest(proposalId: string): Promise<TransactionRequest> {
|
|
1792
|
-
const { finalRequest } = await this.prepareProposalExecution(proposalId);
|
|
2023
|
+
const { finalRequest, anchor } = await this.prepareProposalExecution(proposalId);
|
|
2024
|
+
anchor.free();
|
|
1793
2025
|
return finalRequest;
|
|
1794
2026
|
}
|
|
1795
2027
|
|
|
@@ -1799,22 +2031,21 @@ export class Multisig {
|
|
|
1799
2031
|
* @param proposalId - The proposal commitment/ID
|
|
1800
2032
|
*/
|
|
1801
2033
|
async executeProposal(proposalId: string): Promise<void> {
|
|
1802
|
-
const { metadata, finalRequest, proposal } =
|
|
2034
|
+
const { metadata, finalRequest, proposal, anchor } =
|
|
2035
|
+
await this.prepareProposalExecution(proposalId);
|
|
1803
2036
|
|
|
1804
|
-
if (metadata.proposalType === 'switch_guardian') {
|
|
1805
|
-
// #417: import notes embedded in pending proposals from the old
|
|
1806
|
-
// GUARDIAN. Must run before the switch executes and repoints;
|
|
1807
|
-
// best-effort and bounded — see preservePreSwitchProposalNotes.
|
|
1808
|
-
await this.preservePreSwitchProposalNotes();
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
// Execute at the proposal's anchored reference block, so the summary the
|
|
1812
|
-
// cosigners signed reproduces exactly. The anchor was already checked
|
|
1813
|
-
// against the summary's block commitment during binding verification.
|
|
1814
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
1815
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1816
2037
|
try {
|
|
1817
|
-
|
|
2038
|
+
if (metadata.proposalType === 'switch_guardian') {
|
|
2039
|
+
// #417: import notes embedded in pending proposals from the old
|
|
2040
|
+
// GUARDIAN. Must run before the switch executes and repoints;
|
|
2041
|
+
// best-effort and bounded — see preservePreSwitchProposalNotes.
|
|
2042
|
+
await this.preservePreSwitchProposalNotes();
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Execute at the proposal's anchored reference block, so the summary the
|
|
2046
|
+
// cosigners signed reproduces exactly. The anchor was already checked
|
|
2047
|
+
// against the summary's block commitment during binding verification.
|
|
2048
|
+
await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), finalRequest, anchor);
|
|
1818
2049
|
} finally {
|
|
1819
2050
|
anchor.free();
|
|
1820
2051
|
}
|
|
@@ -1871,7 +2102,7 @@ export class Multisig {
|
|
|
1871
2102
|
}
|
|
1872
2103
|
}
|
|
1873
2104
|
|
|
1874
|
-
proposal.status
|
|
2105
|
+
this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
|
|
1875
2106
|
}
|
|
1876
2107
|
|
|
1877
2108
|
/**
|
|
@@ -2033,6 +2264,8 @@ export class Multisig {
|
|
|
2033
2264
|
);
|
|
2034
2265
|
}
|
|
2035
2266
|
|
|
2267
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
2268
|
+
|
|
2036
2269
|
return this.assembleCustomAdvice(
|
|
2037
2270
|
proposalId,
|
|
2038
2271
|
signaturesForExecution,
|
|
@@ -2041,6 +2274,34 @@ export class Multisig {
|
|
|
2041
2274
|
);
|
|
2042
2275
|
}
|
|
2043
2276
|
|
|
2277
|
+
private buildCosignerAdviceEntry(
|
|
2278
|
+
cosignerSig: ProposalSignatureEntry,
|
|
2279
|
+
signerCommitment: Word,
|
|
2280
|
+
txCommitmentHex: string,
|
|
2281
|
+
): { key: Word; values: Felt[] } {
|
|
2282
|
+
const approval = cosignerSig.signature;
|
|
2283
|
+
const txCommitment = Word.fromHex(txCommitmentHex);
|
|
2284
|
+
if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
|
|
2285
|
+
if (!approval.publicKey) {
|
|
2286
|
+
throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
|
|
2287
|
+
}
|
|
2288
|
+
return buildEip712SignatureAdviceEntry(
|
|
2289
|
+
signerCommitment,
|
|
2290
|
+
txCommitment,
|
|
2291
|
+
approval.signature,
|
|
2292
|
+
approval.publicKey,
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
const signature = Signature.deserialize(
|
|
2297
|
+
signatureHexToBytes(approval.signature, approval.scheme),
|
|
2298
|
+
);
|
|
2299
|
+
if (approval.scheme === 'ecdsa' && approval.publicKey) {
|
|
2300
|
+
assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
|
|
2301
|
+
}
|
|
2302
|
+
return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2044
2305
|
private async assembleCustomAdvice(
|
|
2045
2306
|
proposalId: string,
|
|
2046
2307
|
signaturesForExecution: ProposalSignatureEntry[],
|
|
@@ -2077,22 +2338,8 @@ export class Multisig {
|
|
|
2077
2338
|
}
|
|
2078
2339
|
|
|
2079
2340
|
const signerCommitment = Word.fromHex(signerCommitmentHex);
|
|
2080
|
-
const
|
|
2081
|
-
cosignerSig
|
|
2082
|
-
cosignerSig.signature.scheme,
|
|
2083
|
-
);
|
|
2084
|
-
const signature = Signature.deserialize(sigBytes);
|
|
2085
|
-
if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
|
|
2086
|
-
assertEcdsaSignatureRecoverable(
|
|
2087
|
-
cosignerSig.signature.signature,
|
|
2088
|
-
normalizedTxCommitmentHex,
|
|
2089
|
-
ecdsaPublicKey,
|
|
2090
|
-
);
|
|
2091
|
-
}
|
|
2092
|
-
const { key, values } = buildSignatureAdviceEntry(
|
|
2093
|
-
signerCommitment,
|
|
2094
|
-
createTxCommitmentWord(),
|
|
2095
|
-
signature,
|
|
2341
|
+
const { key, values } = this.buildCosignerAdviceEntry(
|
|
2342
|
+
cosignerSig, signerCommitment, normalizedTxCommitmentHex,
|
|
2096
2343
|
);
|
|
2097
2344
|
const keyHex = normalizeHexWord(key.toHex());
|
|
2098
2345
|
if (adviceMapKeys.has(keyHex)) {
|
|
@@ -2148,9 +2395,16 @@ export class Multisig {
|
|
|
2148
2395
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
2149
2396
|
}
|
|
2150
2397
|
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2398
|
+
/**
|
|
2399
|
+
* The returned `anchor` is the block the request has to execute at; the
|
|
2400
|
+
* caller owns it and frees it once submitted.
|
|
2401
|
+
*/
|
|
2402
|
+
private async prepareProposalExecution(proposalId: string): Promise<{
|
|
2403
|
+
finalRequest: TransactionRequest;
|
|
2404
|
+
metadata: ProposalMetadata;
|
|
2405
|
+
proposal: Proposal;
|
|
2406
|
+
anchor: ChainAnchor;
|
|
2407
|
+
}> {
|
|
2154
2408
|
const proposal = this.getLocalProposal(proposalId);
|
|
2155
2409
|
if (!proposal) {
|
|
2156
2410
|
throw new Error(`Proposal not found: ${proposalId}`);
|
|
@@ -2213,6 +2467,8 @@ export class Multisig {
|
|
|
2213
2467
|
);
|
|
2214
2468
|
}
|
|
2215
2469
|
|
|
2470
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
2471
|
+
|
|
2216
2472
|
const normalizedSignerCommitments = new Set(
|
|
2217
2473
|
this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
|
|
2218
2474
|
);
|
|
@@ -2246,22 +2502,8 @@ export class Multisig {
|
|
|
2246
2502
|
}
|
|
2247
2503
|
|
|
2248
2504
|
const signerCommitment = Word.fromHex(signerCommitmentHex);
|
|
2249
|
-
const
|
|
2250
|
-
cosignerSig
|
|
2251
|
-
cosignerSig.signature.scheme,
|
|
2252
|
-
);
|
|
2253
|
-
const signature = Signature.deserialize(sigBytes);
|
|
2254
|
-
if (cosignerSig.signature.scheme === 'ecdsa' && ecdsaPublicKey) {
|
|
2255
|
-
assertEcdsaSignatureRecoverable(
|
|
2256
|
-
cosignerSig.signature.signature,
|
|
2257
|
-
normalizedTxCommitmentHex,
|
|
2258
|
-
ecdsaPublicKey,
|
|
2259
|
-
);
|
|
2260
|
-
}
|
|
2261
|
-
const { key, values } = buildSignatureAdviceEntry(
|
|
2262
|
-
signerCommitment,
|
|
2263
|
-
createTxCommitmentWord(),
|
|
2264
|
-
signature,
|
|
2505
|
+
const { key, values } = this.buildCosignerAdviceEntry(
|
|
2506
|
+
cosignerSig, signerCommitment, normalizedTxCommitmentHex,
|
|
2265
2507
|
);
|
|
2266
2508
|
const keyHex = normalizeHexWord(key.toHex());
|
|
2267
2509
|
if (adviceMapKeys.has(keyHex)) {
|
|
@@ -2317,17 +2559,18 @@ export class Multisig {
|
|
|
2317
2559
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
2318
2560
|
}
|
|
2319
2561
|
|
|
2320
|
-
|
|
2321
|
-
// ours; without the release it leaks once per execute.
|
|
2322
|
-
const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
|
|
2323
|
-
let finalRequest;
|
|
2562
|
+
const anchor = this.requireProposalAnchor(proposalId, metadata);
|
|
2324
2563
|
try {
|
|
2325
|
-
finalRequest = await this.buildTransactionRequestFromMetadata(
|
|
2326
|
-
|
|
2327
|
-
|
|
2564
|
+
const finalRequest = await this.buildTransactionRequestFromMetadata(
|
|
2565
|
+
metadata,
|
|
2566
|
+
proposalRequestBinding(txSummary, anchor, saltHex),
|
|
2567
|
+
adviceMap,
|
|
2568
|
+
);
|
|
2569
|
+
return { finalRequest, metadata, proposal, anchor };
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
anchor.free();
|
|
2572
|
+
throw error;
|
|
2328
2573
|
}
|
|
2329
|
-
|
|
2330
|
-
return { finalRequest, metadata, proposal };
|
|
2331
2574
|
}
|
|
2332
2575
|
|
|
2333
2576
|
/**
|
|
@@ -2350,6 +2593,8 @@ export class Multisig {
|
|
|
2350
2593
|
signatureHex: s.signature.signature,
|
|
2351
2594
|
scheme: s.signature.scheme,
|
|
2352
2595
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
2596
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
2597
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
2353
2598
|
timestamp: s.timestamp,
|
|
2354
2599
|
}))
|
|
2355
2600
|
: [];
|
|
@@ -2386,6 +2631,8 @@ export class Multisig {
|
|
|
2386
2631
|
signatureHex: s.signature.signature,
|
|
2387
2632
|
scheme: s.signature.scheme,
|
|
2388
2633
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
2634
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
2635
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
2389
2636
|
timestamp: s.timestamp,
|
|
2390
2637
|
})),
|
|
2391
2638
|
metadata: proposal.metadata,
|
|
@@ -2467,14 +2714,17 @@ export class Multisig {
|
|
|
2467
2714
|
this.signerCommitments,
|
|
2468
2715
|
localSignatureContext,
|
|
2469
2716
|
).entries();
|
|
2470
|
-
proposal.signatures = canonicalizedSignatures;
|
|
2471
|
-
|
|
2472
|
-
// Update status
|
|
2473
2717
|
const proposalType = proposal.metadata?.proposalType;
|
|
2474
2718
|
const signaturesRequired = proposalType
|
|
2475
2719
|
? this.getEffectiveThreshold(proposalType)
|
|
2476
2720
|
: this.threshold;
|
|
2477
|
-
|
|
2721
|
+
// A fresh object rather than an in-place write: a sync that snapshotted
|
|
2722
|
+
// the cache before this signature tells the two apart by identity.
|
|
2723
|
+
this.proposals.set(proposal.id, {
|
|
2724
|
+
...proposal,
|
|
2725
|
+
signatures: canonicalizedSignatures,
|
|
2726
|
+
status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
|
|
2727
|
+
});
|
|
2478
2728
|
|
|
2479
2729
|
// Return updated JSON
|
|
2480
2730
|
return this.exportProposalToJson(proposal.id);
|
|
@@ -2493,7 +2743,29 @@ export class Multisig {
|
|
|
2493
2743
|
return txSummaryCommitment;
|
|
2494
2744
|
}
|
|
2495
2745
|
|
|
2746
|
+
/**
|
|
2747
|
+
* Verifies that a proposal's metadata reconstructs its signed summary
|
|
2748
|
+
* commitment and records the outcome in {@link Proposal.verification}:
|
|
2749
|
+
* `verified`, or `failed` with the message and whether the failure looked
|
|
2750
|
+
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2751
|
+
* while `syncProposals` keeps going with the outcome recorded.
|
|
2752
|
+
*/
|
|
2496
2753
|
private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
|
|
2754
|
+
try {
|
|
2755
|
+
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2756
|
+
proposal.verification = { status: 'verified' };
|
|
2757
|
+
return commitment;
|
|
2758
|
+
} catch (error) {
|
|
2759
|
+
proposal.verification = {
|
|
2760
|
+
status: 'failed',
|
|
2761
|
+
retryable: isTransientRpcError(error),
|
|
2762
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2763
|
+
};
|
|
2764
|
+
throw error;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
private async checkProposalMetadataBinding(proposal: Proposal): Promise<string> {
|
|
2497
2769
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
2498
2770
|
|
|
2499
2771
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
@@ -2519,17 +2791,39 @@ export class Multisig {
|
|
|
2519
2791
|
return txSummaryCommitment;
|
|
2520
2792
|
}
|
|
2521
2793
|
|
|
2794
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2795
|
+
// type, switch_guardian included (as in the Rust SDK): a mismatched salt
|
|
2796
|
+
// would otherwise collect signatures and only fail in the VM.
|
|
2797
|
+
const binding = proposalRequestBinding(
|
|
2798
|
+
summary,
|
|
2799
|
+
anchor,
|
|
2800
|
+
this.requireProposalSaltHex(proposal.id, proposal.metadata),
|
|
2801
|
+
);
|
|
2802
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2803
|
+
throw new Error(
|
|
2804
|
+
`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
|
|
2805
|
+
);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2522
2808
|
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
2523
|
-
// Re-execution would mutate the WASM account twice. The proposal ID
|
|
2524
|
-
//
|
|
2809
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2810
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2811
|
+
// binding checks for this type.
|
|
2525
2812
|
return txSummaryCommitment;
|
|
2526
2813
|
}
|
|
2527
2814
|
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2815
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2816
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2817
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2818
|
+
// that never held these notes reproduces a different commitment.
|
|
2819
|
+
if (
|
|
2820
|
+
proposal.metadata.proposalType === 'consume_notes' &&
|
|
2821
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
|
|
2822
|
+
) {
|
|
2823
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2824
|
+
}
|
|
2531
2825
|
|
|
2532
|
-
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata,
|
|
2826
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2533
2827
|
const webClient = await this.getRawClient();
|
|
2534
2828
|
const reconstructed = await executeForSummaryAt(webClient, this._accountId, request, anchor);
|
|
2535
2829
|
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
@@ -2554,24 +2848,19 @@ export class Multisig {
|
|
|
2554
2848
|
* Reads a proposal's salt. Throws when absent, because there is nothing to fall
|
|
2555
2849
|
* back to.
|
|
2556
2850
|
*
|
|
2557
|
-
* The
|
|
2558
|
-
*
|
|
2559
|
-
*
|
|
2560
|
-
*
|
|
2561
|
-
*
|
|
2562
|
-
* why the fallback this replaces was correct when it was written.
|
|
2563
|
-
*
|
|
2564
|
-
* A declared salt also bypasses miden-client's zero-fee early return, so this holds
|
|
2565
|
-
* on a chain that charges nothing exactly as on one that charges.
|
|
2851
|
+
* The salt goes into the request's multisig auth args and the summary binds it in
|
|
2852
|
+
* its user params, so `summarySalt(summary)` reads the value the cosigners signed
|
|
2853
|
+
* over. It is not a substitute for this field: a request has to be rebuilt before
|
|
2854
|
+
* any summary exists, and a proposal GUARDIAN serves may pair a summary with
|
|
2855
|
+
* metadata that names another salt, which the binding check reports by name.
|
|
2566
2856
|
*/
|
|
2567
2857
|
private requireProposalSaltHex(proposalId: string, metadata: ProposalMetadata): string {
|
|
2568
2858
|
const saltHex: unknown = metadata.saltHex;
|
|
2569
2859
|
|
|
2570
2860
|
if (saltHex === undefined || saltHex === null || saltHex === '') {
|
|
2571
2861
|
throw new Error(
|
|
2572
|
-
`Proposal ${proposalId} has no salt; its request cannot be rebuilt
|
|
2573
|
-
'
|
|
2574
|
-
'to the salt',
|
|
2862
|
+
`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
|
|
2863
|
+
'salt its auth args and signed summary bind',
|
|
2575
2864
|
);
|
|
2576
2865
|
}
|
|
2577
2866
|
|
|
@@ -2614,13 +2903,60 @@ export class Multisig {
|
|
|
2614
2903
|
return chainAnchorFromBase64(metadata.chainAnchor);
|
|
2615
2904
|
}
|
|
2616
2905
|
|
|
2906
|
+
/**
|
|
2907
|
+
* An expired approval aborts in the auth procedure only at execution.
|
|
2908
|
+
* The summary carries the deadline, so callers check it against the sync
|
|
2909
|
+
* height before assembling advice or requesting the GUARDIAN ack.
|
|
2910
|
+
*/
|
|
2911
|
+
private async assertApprovalNotExpired(
|
|
2912
|
+
proposalId: string,
|
|
2913
|
+
summary: TransactionSummary,
|
|
2914
|
+
): Promise<void> {
|
|
2915
|
+
const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
|
|
2916
|
+
if (expirationBlockNum === undefined) {
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const webClient = await this.getRawClient();
|
|
2920
|
+
const syncHeight = await webClient.getSyncHeight();
|
|
2921
|
+
if (syncHeight >= expirationBlockNum) {
|
|
2922
|
+
throw new Error(
|
|
2923
|
+
`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
|
|
2924
|
+
`block ${syncHeight}, so the collected signatures no longer authorize it`,
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* Rebuilds a proposal's request from its metadata under `binding`, so the
|
|
2931
|
+
* summary it produces is the one the cosigners signed.
|
|
2932
|
+
*/
|
|
2617
2933
|
private async buildTransactionRequestFromMetadata(
|
|
2618
2934
|
metadata: ProposalMetadata,
|
|
2619
|
-
|
|
2935
|
+
binding: ProposalRequestBinding,
|
|
2620
2936
|
signatureAdviceMap?: AdviceMap,
|
|
2621
2937
|
): Promise<TransactionRequest> {
|
|
2622
|
-
|
|
2938
|
+
// The builders read `.toHex()` and allocate their own Word, so this handle
|
|
2939
|
+
// stays ours; without the release it leaks once per rebuild.
|
|
2940
|
+
const salt = Word.fromHex(binding.saltHex);
|
|
2941
|
+
try {
|
|
2942
|
+
return await this.buildTransactionRequestWithOptions(metadata, {
|
|
2943
|
+
accountId: this._accountId,
|
|
2944
|
+
boundBlockNum: binding.boundBlockNum,
|
|
2945
|
+
approvalExpirationDelta: binding.approvalExpirationDelta,
|
|
2946
|
+
salt,
|
|
2947
|
+
signatureAdviceMap,
|
|
2948
|
+
signatureScheme: this.signer.scheme,
|
|
2949
|
+
});
|
|
2950
|
+
} finally {
|
|
2951
|
+
salt.free?.();
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2623
2954
|
|
|
2955
|
+
private async buildTransactionRequestWithOptions(
|
|
2956
|
+
metadata: ProposalMetadata,
|
|
2957
|
+
requestOptions: MultisigRequestOptions,
|
|
2958
|
+
): Promise<TransactionRequest> {
|
|
2959
|
+
const webClient = await this.getRawClient();
|
|
2624
2960
|
switch (metadata.proposalType) {
|
|
2625
2961
|
case 'add_signer':
|
|
2626
2962
|
case 'remove_signer':
|
|
@@ -2629,7 +2965,7 @@ export class Multisig {
|
|
|
2629
2965
|
webClient,
|
|
2630
2966
|
metadata.targetThreshold,
|
|
2631
2967
|
metadata.targetSignerCommitments,
|
|
2632
|
-
|
|
2968
|
+
requestOptions,
|
|
2633
2969
|
);
|
|
2634
2970
|
return request;
|
|
2635
2971
|
}
|
|
@@ -2637,7 +2973,7 @@ export class Multisig {
|
|
|
2637
2973
|
const { request } = await buildUpdateGuardianTransactionRequest(
|
|
2638
2974
|
webClient,
|
|
2639
2975
|
metadata.newGuardianPubkey,
|
|
2640
|
-
|
|
2976
|
+
requestOptions,
|
|
2641
2977
|
);
|
|
2642
2978
|
return request;
|
|
2643
2979
|
}
|
|
@@ -2646,7 +2982,7 @@ export class Multisig {
|
|
|
2646
2982
|
webClient,
|
|
2647
2983
|
metadata.targetProcedure,
|
|
2648
2984
|
metadata.targetThreshold,
|
|
2649
|
-
|
|
2985
|
+
requestOptions,
|
|
2650
2986
|
);
|
|
2651
2987
|
return request;
|
|
2652
2988
|
}
|
|
@@ -2654,29 +2990,12 @@ export class Multisig {
|
|
|
2654
2990
|
// v1/v2 dispatch for issue #229 / FR-009.
|
|
2655
2991
|
const version = metadata.metadataVersion;
|
|
2656
2992
|
if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
const decoded: Note[] = [];
|
|
2664
|
-
for (let i = 0; i < embedded.length; i++) {
|
|
2665
|
-
const note = noteFromBase64(embedded[i], Note);
|
|
2666
|
-
// Normalize both sides; matches the file's other hex comparisons.
|
|
2667
|
-
const embeddedId = normalizeHexWord(note.id().toString());
|
|
2668
|
-
const declaredId = normalizeHexWord(metadata.noteIds[i]);
|
|
2669
|
-
if (embeddedId !== declaredId) {
|
|
2670
|
-
throw new NoteBindingMismatchError(
|
|
2671
|
-
`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
|
|
2672
|
-
);
|
|
2673
|
-
}
|
|
2674
|
-
decoded.push(note);
|
|
2675
|
-
}
|
|
2676
|
-
const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
|
|
2677
|
-
salt,
|
|
2678
|
-
signatureAdviceMap,
|
|
2679
|
-
});
|
|
2993
|
+
const decoded = decodeEmbeddedConsumeNotes(metadata);
|
|
2994
|
+
const { request } = await buildConsumeNotesTransactionRequestFromNotes(
|
|
2995
|
+
webClient,
|
|
2996
|
+
decoded,
|
|
2997
|
+
requestOptions,
|
|
2998
|
+
);
|
|
2680
2999
|
return request;
|
|
2681
3000
|
}
|
|
2682
3001
|
if (version === undefined || version === 1) {
|
|
@@ -2688,25 +3007,25 @@ export class Multisig {
|
|
|
2688
3007
|
const { request } = await buildConsumeNotesTransactionRequest(
|
|
2689
3008
|
webClient,
|
|
2690
3009
|
metadata.noteIds,
|
|
2691
|
-
|
|
3010
|
+
requestOptions,
|
|
2692
3011
|
);
|
|
2693
3012
|
return request;
|
|
2694
3013
|
}
|
|
2695
3014
|
throw new UnsupportedMetadataVersionError(version);
|
|
2696
3015
|
}
|
|
2697
3016
|
case 'p2id': {
|
|
2698
|
-
const { request } = buildP2idTransactionRequest(
|
|
3017
|
+
const { request } = await buildP2idTransactionRequest(
|
|
3018
|
+
webClient,
|
|
2699
3019
|
this._accountId,
|
|
2700
3020
|
metadata.recipientId,
|
|
2701
3021
|
metadata.faucetId,
|
|
2702
3022
|
BigInt(metadata.amount),
|
|
2703
3023
|
{
|
|
2704
|
-
|
|
2705
|
-
signatureAdviceMap,
|
|
3024
|
+
...requestOptions,
|
|
2706
3025
|
noteType: parseP2idNoteType(metadata.noteType),
|
|
2707
3026
|
reclaimHeight: metadata.reclaimHeight,
|
|
2708
3027
|
timelockHeight: metadata.timelockHeight,
|
|
2709
|
-
}
|
|
3028
|
+
},
|
|
2710
3029
|
);
|
|
2711
3030
|
return request;
|
|
2712
3031
|
}
|
|
@@ -2718,3 +3037,31 @@ export class Multisig {
|
|
|
2718
3037
|
}
|
|
2719
3038
|
|
|
2720
3039
|
}
|
|
3040
|
+
|
|
3041
|
+
/**
|
|
3042
|
+
* Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
|
|
3043
|
+
* is the note its declared id names (spec 006 FR-007).
|
|
3044
|
+
*/
|
|
3045
|
+
function decodeEmbeddedConsumeNotes(metadata: ConsumeNotesProposalMetadata): Note[] {
|
|
3046
|
+
const embedded = metadata.notes ?? [];
|
|
3047
|
+
const noteIds = metadata.noteIds ?? [];
|
|
3048
|
+
if (embedded.length !== noteIds.length) {
|
|
3049
|
+
throw new NoteBindingMismatchError(
|
|
3050
|
+
`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`,
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
const decoded: Note[] = [];
|
|
3054
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
3055
|
+
const note = noteFromBase64(embedded[i], Note);
|
|
3056
|
+
// Normalize both sides; matches the file's other hex comparisons.
|
|
3057
|
+
const embeddedId = normalizeHexWord(note.id().toString());
|
|
3058
|
+
const declaredId = normalizeHexWord(noteIds[i]);
|
|
3059
|
+
if (embeddedId !== declaredId) {
|
|
3060
|
+
throw new NoteBindingMismatchError(
|
|
3061
|
+
`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
decoded.push(note);
|
|
3065
|
+
}
|
|
3066
|
+
return decoded;
|
|
3067
|
+
}
|