@openzeppelin/miden-multisig-client 0.17.0 → 0.18.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -46
- package/dist/account/builder.d.ts +4 -4
- package/dist/account/builder.d.ts.map +1 -1
- package/dist/account/builder.js +17 -7
- package/dist/account/builder.js.map +1 -1
- package/dist/account/layout.d.ts +5 -5
- package/dist/account/layout.d.ts.map +1 -1
- package/dist/account/layout.js +5 -5
- package/dist/account/layout.js.map +1 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +79 -6
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +28 -31
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +42 -46
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig/consumeNotesErrors.d.ts +13 -1
- package/dist/multisig/consumeNotesErrors.d.ts.map +1 -1
- package/dist/multisig/consumeNotesErrors.js +17 -0
- package/dist/multisig/consumeNotesErrors.js.map +1 -1
- package/dist/multisig/signing.d.ts +1 -1
- package/dist/multisig/signing.d.ts.map +1 -1
- package/dist/multisig/signing.js +8 -3
- package/dist/multisig/signing.js.map +1 -1
- package/dist/multisig.d.ts +145 -18
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +493 -162
- package/dist/multisig.js.map +1 -1
- package/dist/procedures.d.ts +7 -7
- package/dist/procedures.js +7 -7
- package/dist/proposal/factory.d.ts.map +1 -1
- package/dist/proposal/factory.js +7 -0
- package/dist/proposal/factory.js.map +1 -1
- package/dist/prover/workflow.d.ts +7 -3
- package/dist/prover/workflow.d.ts.map +1 -1
- package/dist/prover/workflow.js +7 -5
- package/dist/prover/workflow.js.map +1 -1
- package/dist/raw-client.d.ts +1 -0
- package/dist/raw-client.d.ts.map +1 -1
- package/dist/raw-client.js +10 -2
- package/dist/raw-client.js.map +1 -1
- package/dist/recovery/publicNoteBackfill.js +1 -1
- package/dist/recovery/publicNoteBackfill.js.map +1 -1
- package/dist/retry/classify.d.ts +3 -0
- package/dist/retry/classify.d.ts.map +1 -1
- package/dist/retry/classify.js +2 -2
- package/dist/retry/classify.js.map +1 -1
- package/dist/signer.d.ts +1 -0
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +1 -0
- package/dist/signer.js.map +1 -1
- package/dist/signers/index.d.ts +1 -0
- package/dist/signers/index.d.ts.map +1 -1
- package/dist/signers/index.js +1 -0
- package/dist/signers/index.js.map +1 -1
- package/dist/signers/ledger.d.ts +25 -0
- package/dist/signers/ledger.d.ts.map +1 -0
- package/dist/signers/ledger.js +96 -0
- package/dist/signers/ledger.js.map +1 -0
- package/dist/state/adopt.d.ts +45 -0
- package/dist/state/adopt.d.ts.map +1 -0
- package/dist/state/adopt.js +101 -0
- package/dist/state/adopt.js.map +1 -0
- package/dist/transaction/authArgs.d.ts +57 -0
- package/dist/transaction/authArgs.d.ts.map +1 -0
- package/dist/transaction/authArgs.js +108 -0
- package/dist/transaction/authArgs.js.map +1 -0
- package/dist/transaction/consumeNotes.d.ts +9 -5
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +8 -23
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/transaction/noteAuthentication.d.ts +39 -0
- package/dist/transaction/noteAuthentication.d.ts.map +1 -0
- package/dist/transaction/noteAuthentication.js +94 -0
- package/dist/transaction/noteAuthentication.js.map +1 -0
- package/dist/transaction/options.d.ts +25 -0
- package/dist/transaction/options.d.ts.map +1 -1
- package/dist/transaction/p2id.d.ts +3 -2
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +36 -27
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction/summary.d.ts +126 -22
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +164 -22
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction/updateGuardian.d.ts +3 -3
- package/dist/transaction/updateGuardian.d.ts.map +1 -1
- package/dist/transaction/updateGuardian.js +5 -16
- package/dist/transaction/updateGuardian.js.map +1 -1
- package/dist/transaction/updateProcedureThreshold.d.ts +3 -3
- package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
- package/dist/transaction/updateProcedureThreshold.js +6 -16
- package/dist/transaction/updateProcedureThreshold.js.map +1 -1
- package/dist/transaction/updateSigners.d.ts +3 -3
- package/dist/transaction/updateSigners.d.ts.map +1 -1
- package/dist/transaction/updateSigners.js +9 -16
- package/dist/transaction/updateSigners.js.map +1 -1
- package/dist/transaction.d.ts +3 -2
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +3 -2
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +37 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +8 -0
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +80 -0
- package/dist/utils/eip712.d.ts.map +1 -0
- package/dist/utils/eip712.js +49 -0
- package/dist/utils/eip712.js.map +1 -0
- package/dist/utils/signature.d.ts +4 -0
- package/dist/utils/signature.d.ts.map +1 -1
- package/dist/utils/signature.js +49 -1
- package/dist/utils/signature.js.map +1 -1
- package/package.json +11 -6
- package/src/account/builder.ts +18 -7
- package/src/account/layout.ts +5 -5
- package/src/client.ts +94 -6
- package/src/index.ts +24 -3
- package/src/multisig/authArgErrors.ts +47 -53
- package/src/multisig/consumeNotesErrors.ts +20 -1
- package/src/multisig/signing.ts +8 -2
- package/src/multisig.ts +614 -205
- package/src/procedures.ts +7 -7
- package/src/proposal/factory.ts +7 -0
- package/src/prover/workflow.ts +7 -10
- package/src/raw-client.ts +11 -7
- package/src/recovery/publicNoteBackfill.ts +1 -1
- package/src/retry/classify.ts +3 -3
- package/src/signer.ts +1 -0
- package/src/signers/index.ts +1 -0
- package/src/signers/ledger.ts +122 -0
- package/src/state/adopt.ts +132 -0
- package/src/transaction/authArgs.ts +142 -0
- package/src/transaction/consumeNotes.ts +23 -30
- package/src/transaction/noteAuthentication.ts +136 -0
- package/src/transaction/options.ts +27 -0
- package/src/transaction/p2id.ts +45 -34
- package/src/transaction/summary.ts +239 -30
- package/src/transaction/updateGuardian.ts +8 -22
- package/src/transaction/updateProcedureThreshold.ts +8 -20
- package/src/transaction/updateSigners.ts +11 -22
- package/src/transaction.ts +18 -1
- package/src/types/proposal.ts +36 -5
- package/src/utils/eip712.ts +57 -0
- package/src/utils/signature.ts +57 -0
- package/src/prover/test-node.d.ts +0 -6
package/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,
|
|
@@ -43,7 +44,12 @@ import {
|
|
|
43
44
|
chainAnchorFromBase64,
|
|
44
45
|
chainAnchorToBase64,
|
|
45
46
|
executeForSummary,
|
|
46
|
-
|
|
47
|
+
executeForSummaryAtTip,
|
|
48
|
+
isStaleChainError,
|
|
49
|
+
prepareTipExecution,
|
|
50
|
+
syncToBoundBlock,
|
|
51
|
+
summaryApprovalExpirationBlockNum,
|
|
52
|
+
summarySalt,
|
|
47
53
|
buildUpdateSignersTransactionRequest,
|
|
48
54
|
buildUpdateProcedureThresholdTransactionRequest,
|
|
49
55
|
buildUpdateGuardianTransactionRequest,
|
|
@@ -55,9 +61,13 @@ import {
|
|
|
55
61
|
type P2ideHeightOptions,
|
|
56
62
|
} from './transaction.js';
|
|
57
63
|
import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
|
|
64
|
+
import type { MultisigRequestOptions } from './transaction/options.js';
|
|
65
|
+
import { validateMultisigConfig } from './account/builder.js';
|
|
66
|
+
import { ensureNotesAuthenticated } from './transaction/noteAuthentication.js';
|
|
58
67
|
import {
|
|
59
68
|
CONSUME_NOTES_METADATA_VERSION_V2,
|
|
60
69
|
MAX_CONSUME_NOTES_METADATA_BYTES,
|
|
70
|
+
type ConsumeNotesProposalMetadata,
|
|
61
71
|
} from './types/proposal.js';
|
|
62
72
|
import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
|
|
63
73
|
import {
|
|
@@ -74,6 +84,7 @@ import {
|
|
|
74
84
|
} from './utils/encoding.js';
|
|
75
85
|
import {
|
|
76
86
|
assertEcdsaSignatureRecoverable,
|
|
87
|
+
buildEip712SignatureAdviceEntry,
|
|
77
88
|
buildSignatureAdviceEntry,
|
|
78
89
|
normalizeSignerCommitment,
|
|
79
90
|
signatureHexToBytes,
|
|
@@ -116,7 +127,9 @@ import {
|
|
|
116
127
|
resolveRpcConfig,
|
|
117
128
|
type ResolvedRpcConfig,
|
|
118
129
|
} from './rpc/config.js';
|
|
130
|
+
import { isTransientRpcError } from './rpc/errors.js';
|
|
119
131
|
import { retryRpcRead } from './rpc/retry.js';
|
|
132
|
+
import { isSafeToAdoptGuardianState, readOnChainCommitment } from './state/adopt.js';
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
135
|
* Result of fetching account state from GUARDIAN.
|
|
@@ -146,6 +159,13 @@ export interface AccountStateVerificationResult {
|
|
|
146
159
|
export interface CreateProposalOptions {
|
|
147
160
|
/** Proposal nonce; defaults to `Date.now()`. */
|
|
148
161
|
nonce?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Blocks after the block the proposal binds by which the transaction must be
|
|
164
|
+
* included; past that the approvers' signatures no longer authorize it. The
|
|
165
|
+
* summary binds it, so the executing party can neither shorten nor extend it.
|
|
166
|
+
* Omitted, the approval never expires (the upstream default).
|
|
167
|
+
*/
|
|
168
|
+
approvalExpirationDelta?: number;
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
export interface CreateSignerProposalOptions extends CreateProposalOptions {
|
|
@@ -211,6 +231,64 @@ function resolveProposalNonce(
|
|
|
211
231
|
return options.nonce ?? Date.now();
|
|
212
232
|
}
|
|
213
233
|
|
|
234
|
+
/**
|
|
235
|
+
* What a rebuild of a proposal's request pins so the signed summary
|
|
236
|
+
* reproduces: the salt, the block its anchor names, and the approval
|
|
237
|
+
* expiration the summary binds, as the delta the builders take.
|
|
238
|
+
*/
|
|
239
|
+
interface ProposalRequestBinding {
|
|
240
|
+
saltHex: string;
|
|
241
|
+
boundBlockNum: number;
|
|
242
|
+
approvalExpirationDelta: number | undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function proposalRequestBinding(
|
|
246
|
+
summary: TransactionSummary,
|
|
247
|
+
anchor: ChainAnchor,
|
|
248
|
+
saltHex: string,
|
|
249
|
+
): ProposalRequestBinding {
|
|
250
|
+
const boundBlockNum = anchor.blockNum();
|
|
251
|
+
return {
|
|
252
|
+
saltHex: normalizeHexWord(saltHex),
|
|
253
|
+
boundBlockNum,
|
|
254
|
+
approvalExpirationDelta: approvalExpirationDeltaOf(
|
|
255
|
+
summaryApprovalExpirationBlockNum(summary),
|
|
256
|
+
boundBlockNum,
|
|
257
|
+
),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The approval expiration delta a rebuild has to pass: the absolute expiration
|
|
263
|
+
* block the summary binds, relative to the bound block. `undefined` for an
|
|
264
|
+
* approval that never expires.
|
|
265
|
+
*/
|
|
266
|
+
function approvalExpirationDeltaOf(
|
|
267
|
+
expirationBlockNum: number | undefined,
|
|
268
|
+
boundBlockNum: number,
|
|
269
|
+
): number | undefined {
|
|
270
|
+
if (expirationBlockNum === undefined) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const delta = expirationBlockNum - boundBlockNum;
|
|
274
|
+
if (delta < 1) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Invalid proposal: approval expires at block ${expirationBlockNum}, at or before the ` +
|
|
277
|
+
`block ${boundBlockNum} its summary binds`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return delta;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function summarySaltHex(summary: TransactionSummary): string {
|
|
284
|
+
const salt = summarySalt(summary);
|
|
285
|
+
try {
|
|
286
|
+
return normalizeHexWord(salt.toHex());
|
|
287
|
+
} finally {
|
|
288
|
+
salt.free?.();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
214
292
|
/**
|
|
215
293
|
* Deadline for `Multisig.preservePreSwitchProposalNotes`: no client in the
|
|
216
294
|
* stack applies request deadlines, and a half-dead old GUARDIAN must not
|
|
@@ -231,6 +309,13 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
|
|
|
231
309
|
/** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
|
|
232
310
|
const MAX_SALT_HEX_DIGITS = 64;
|
|
233
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Consecutive successful listings that must omit a guardian-known proposal
|
|
314
|
+
* not yet listed (a fresh create during read-your-writes lag, or one
|
|
315
|
+
* orphaned by a GUARDIAN repoint) before the sync prunes it.
|
|
316
|
+
*/
|
|
317
|
+
const UNREPORTED_LISTING_MISS_LIMIT = 2;
|
|
318
|
+
|
|
234
319
|
export class Multisig {
|
|
235
320
|
account: Account;
|
|
236
321
|
threshold: number;
|
|
@@ -248,6 +333,24 @@ export class Multisig {
|
|
|
248
333
|
private readonly _accountId: string;
|
|
249
334
|
private readonly midenRpcEndpoint: string;
|
|
250
335
|
private proposals: Map<string, Proposal> = new Map();
|
|
336
|
+
/** Ids GUARDIAN returned on the most recent sync; these prune immediately when dropped. */
|
|
337
|
+
private lastReportedProposalIds: Set<string> = new Set();
|
|
338
|
+
/**
|
|
339
|
+
* Ids GUARDIAN is known to hold: acknowledged `createProposal` pushes,
|
|
340
|
+
* acknowledged `signProposal` signatures, plus every listed id. Only these
|
|
341
|
+
* are subject to miss-based pruning; offline creations and imports GUARDIAN
|
|
342
|
+
* never received are exempt.
|
|
343
|
+
*/
|
|
344
|
+
private guardianKnownProposalIds: Set<string> = new Set();
|
|
345
|
+
/**
|
|
346
|
+
* Consecutive successful listings that omitted a guardian-known proposal
|
|
347
|
+
* not yet listed; at {@link UNREPORTED_LISTING_MISS_LIMIT} it is pruned.
|
|
348
|
+
*/
|
|
349
|
+
private unreportedMissCounts: Map<string, number> = new Map();
|
|
350
|
+
/** Bumped by {@link setGuardianClient}; a sync spanning a bump aborts unapplied. */
|
|
351
|
+
private syncGeneration = 0;
|
|
352
|
+
/** Pending sync shared by overlapping {@link syncProposals} callers. */
|
|
353
|
+
private syncProposalsInFlight?: Promise<Proposal[]>;
|
|
251
354
|
|
|
252
355
|
constructor(
|
|
253
356
|
account: Account,
|
|
@@ -435,6 +538,20 @@ export class Multisig {
|
|
|
435
538
|
}));
|
|
436
539
|
}
|
|
437
540
|
|
|
541
|
+
/**
|
|
542
|
+
* The request options every `create*Proposal` hands its builder: the account,
|
|
543
|
+
* the caller's approval expiration, and the signer's scheme.
|
|
544
|
+
*/
|
|
545
|
+
private proposalRequestOptions(options: {
|
|
546
|
+
approvalExpirationDelta?: number;
|
|
547
|
+
}): Pick<MultisigRequestOptions, 'accountId' | 'approvalExpirationDelta' | 'signatureScheme'> {
|
|
548
|
+
return {
|
|
549
|
+
accountId: this._accountId,
|
|
550
|
+
approvalExpirationDelta: options.approvalExpirationDelta,
|
|
551
|
+
signatureScheme: this.signer.scheme,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
438
555
|
private warnOnOverrideDilution(newNumSigners: number): void {
|
|
439
556
|
const current = this.signerCommitments.length;
|
|
440
557
|
for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
|
|
@@ -454,11 +571,23 @@ export class Multisig {
|
|
|
454
571
|
* survive a switch, and the notes embedded in them can only be imported
|
|
455
572
|
* while the old GUARDIAN is still the current client.
|
|
456
573
|
*
|
|
574
|
+
* Repointing abandons a {@link syncProposals} still in flight: it rejects
|
|
575
|
+
* without applying its listing, though its request to the old GUARDIAN is
|
|
576
|
+
* not cancelled, so callers already awaiting it see the rejection only
|
|
577
|
+
* once that response settles. The reported-id and miss-count bookkeeping
|
|
578
|
+
* is reset; the set of ids GUARDIAN is known to hold is kept on purpose,
|
|
579
|
+
* so proposals orphaned by the repoint expire through the two-miss rule
|
|
580
|
+
* instead of lingering.
|
|
581
|
+
*
|
|
457
582
|
* @param guardianClient - The new GUARDIAN HTTP client
|
|
458
583
|
*/
|
|
459
584
|
setGuardianClient(guardianClient: GuardianHttpClient): void {
|
|
460
585
|
this.guardian = guardianClient;
|
|
461
586
|
this.guardian.setSigner(this.signer);
|
|
587
|
+
this.syncGeneration += 1;
|
|
588
|
+
this.syncProposalsInFlight = undefined;
|
|
589
|
+
this.lastReportedProposalIds = new Set();
|
|
590
|
+
this.unreportedMissCounts = new Map();
|
|
462
591
|
}
|
|
463
592
|
|
|
464
593
|
/**
|
|
@@ -573,66 +702,16 @@ export class Multisig {
|
|
|
573
702
|
incomingAccount: Account,
|
|
574
703
|
localAccount?: Account,
|
|
575
704
|
): 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;
|
|
705
|
+
return isSafeToAdoptGuardianState({
|
|
706
|
+
accountId: this._accountId,
|
|
707
|
+
incomingAccount,
|
|
708
|
+
localAccount,
|
|
709
|
+
readCommitment: () => this.getOnChainCommitment(AccountId.fromHex(this._accountId)),
|
|
710
|
+
});
|
|
605
711
|
}
|
|
606
712
|
|
|
607
713
|
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
|
-
}
|
|
714
|
+
return readOnChainCommitment(this.getMidenRpcEndpoint(), accountId, this.rpcConfig);
|
|
636
715
|
}
|
|
637
716
|
|
|
638
717
|
/**
|
|
@@ -715,12 +794,76 @@ export class Multisig {
|
|
|
715
794
|
}
|
|
716
795
|
|
|
717
796
|
/**
|
|
718
|
-
* Sync proposals from the GUARDIAN server
|
|
797
|
+
* Sync proposals from the GUARDIAN server, reconciling the local cache to
|
|
798
|
+
* the response. GUARDIAN reports only pending proposals, so a proposal it
|
|
799
|
+
* reported on an earlier sync and now omits is pruned immediately. A
|
|
800
|
+
* proposal GUARDIAN holds but has not listed yet (a fresh `createProposal`
|
|
801
|
+
* its read-your-writes has not caught up with, or a proposal orphaned by a
|
|
802
|
+
* {@link setGuardianClient} repoint) is pruned only after
|
|
803
|
+
* {@link UNREPORTED_LISTING_MISS_LIMIT} consecutive listings omit it.
|
|
804
|
+
* Proposals GUARDIAN never received (an `importProposal`, or a
|
|
805
|
+
* `createSwitchGuardianProposalOffline`) are not pruned by listings; an
|
|
806
|
+
* import graduates to the pruned classes once GUARDIAN acknowledges it
|
|
807
|
+
* (listed, or a successful online `signProposal`).
|
|
808
|
+
* Proposals cached or replaced after the sync started are not evaluated
|
|
809
|
+
* by it.
|
|
810
|
+
*
|
|
811
|
+
* Every synced proposal's metadata is checked against its signed summary
|
|
812
|
+
* and the outcome is recorded in {@link Proposal.verification}. One that
|
|
813
|
+
* fails is still cached and returned, so a single stale or corrupt
|
|
814
|
+
* proposal cannot hide the others (issue #462). Verification re-executes
|
|
815
|
+
* each proposal at the chain tip, so the Miden client is synced once first.
|
|
816
|
+
* `failed` with `retryable: true` means a transient node error or chain
|
|
817
|
+
* state this client had not caught up with, worth syncing again;
|
|
818
|
+
* `retryable: false` means the proposal cannot be reproduced and must be
|
|
819
|
+
* re-proposed. `signProposal` and `executeProposal` re-verify and
|
|
820
|
+
* refuse a failed proposal. A failed proposal still counts as reported, so
|
|
821
|
+
* it is pruned like any other once GUARDIAN stops listing it.
|
|
822
|
+
*
|
|
823
|
+
* The response is parsed in full before the cache or the pruning state
|
|
824
|
+
* changes; a payload that does not parse at all rejects and leaves both
|
|
825
|
+
* untouched, so malformed GUARDIAN data is never silently dropped.
|
|
826
|
+
* Signatures added to a cached proposal while the sync was verifying are
|
|
827
|
+
* preserved by its apply, and a proposal executed locally in that window
|
|
828
|
+
* stays `finalized` rather than reverting to the listed pending state.
|
|
829
|
+
* Overlapping callers share the same in-flight promise. A sync that spans a {@link setGuardianClient} repoint rejects
|
|
830
|
+
* without applying its listing.
|
|
831
|
+
*
|
|
832
|
+
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
833
|
+
* `filterVisibleProposals`): callers of this shared client disagree on
|
|
834
|
+
* whether a proposal's `nonce` is the pre-execution or the next account
|
|
835
|
+
* nonce, so the Rust client's `proposal.nonce <= account.nonce()` filter
|
|
836
|
+
* cannot be applied here. This is an intentional TS/Rust surface
|
|
837
|
+
* difference.
|
|
719
838
|
*/
|
|
720
|
-
|
|
839
|
+
syncProposals(): Promise<Proposal[]> {
|
|
840
|
+
if (this.syncProposalsInFlight) {
|
|
841
|
+
return this.syncProposalsInFlight;
|
|
842
|
+
}
|
|
843
|
+
const inFlight = this.reconcileProposals().finally(() => {
|
|
844
|
+
if (this.syncProposalsInFlight === inFlight) {
|
|
845
|
+
this.syncProposalsInFlight = undefined;
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
this.syncProposalsInFlight = inFlight;
|
|
849
|
+
return inFlight;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private async reconcileProposals(): Promise<Proposal[]> {
|
|
853
|
+
const generation = this.syncGeneration;
|
|
854
|
+
const candidates = new Map(this.proposals);
|
|
721
855
|
const deltas = await this.guardian.getDeltaProposals(this._accountId);
|
|
722
856
|
const factory = this.proposalFactory();
|
|
723
857
|
|
|
858
|
+
const reported = new Map<string, { delta: (typeof deltas)[number]; verified: Proposal }>();
|
|
859
|
+
if (deltas.length > 0) {
|
|
860
|
+
// Verification re-executes at the store's sync height; bring it to the
|
|
861
|
+
// tip once for the whole listing. Best effort: a node outage then shows
|
|
862
|
+
// up on each proposal as a retryable failure instead of failing the sync.
|
|
863
|
+
await this.syncChain().catch((error) =>
|
|
864
|
+
console.warn('Could not sync the Miden client before verifying proposals', error),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
724
867
|
for (const delta of deltas) {
|
|
725
868
|
const proposalId = normalizeHexWord(
|
|
726
869
|
computeCommitmentFromTxSummary(delta.deltaPayload.txSummary.data)
|
|
@@ -732,11 +875,63 @@ export class Multisig {
|
|
|
732
875
|
existingProposal?.metadata,
|
|
733
876
|
existingProposal?.signatures ?? [],
|
|
734
877
|
);
|
|
735
|
-
|
|
878
|
+
// The outcome lands on the proposal either way; a failure is reported
|
|
879
|
+
// there rather than failing the sync.
|
|
880
|
+
await this.verifyProposalMetadataBinding(proposal).catch(() => undefined);
|
|
881
|
+
reported.set(proposal.id, { delta, verified: proposal });
|
|
882
|
+
}
|
|
736
883
|
|
|
884
|
+
if (generation !== this.syncGeneration) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
'Sync aborted: the GUARDIAN client was replaced while the sync was in flight'
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const applied: Proposal[] = [];
|
|
891
|
+
for (const { delta, verified } of reported.values()) {
|
|
892
|
+
const current = this.proposals.get(verified.id);
|
|
893
|
+
if (current?.status === 'finalized') {
|
|
894
|
+
applied.push(current);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
applied.push(
|
|
898
|
+
current === undefined
|
|
899
|
+
? verified
|
|
900
|
+
: {
|
|
901
|
+
...factory.fromDelta(delta, verified.id, verified.metadata, current.signatures),
|
|
902
|
+
verification: verified.verification,
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
for (const proposal of applied) {
|
|
737
907
|
this.proposals.set(proposal.id, proposal);
|
|
908
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
738
909
|
}
|
|
739
910
|
|
|
911
|
+
const missCounts = new Map<string, number>();
|
|
912
|
+
for (const [id, snapshot] of candidates) {
|
|
913
|
+
if (reported.has(id) || this.proposals.get(id) !== snapshot) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (this.lastReportedProposalIds.has(id)) {
|
|
917
|
+
this.proposals.delete(id);
|
|
918
|
+
this.guardianKnownProposalIds.delete(id);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (!this.guardianKnownProposalIds.has(id)) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const misses = (this.unreportedMissCounts.get(id) ?? 0) + 1;
|
|
925
|
+
if (misses >= UNREPORTED_LISTING_MISS_LIMIT) {
|
|
926
|
+
this.proposals.delete(id);
|
|
927
|
+
this.guardianKnownProposalIds.delete(id);
|
|
928
|
+
} else {
|
|
929
|
+
missCounts.set(id, misses);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
this.unreportedMissCounts = missCounts;
|
|
933
|
+
this.lastReportedProposalIds = new Set(reported.keys());
|
|
934
|
+
|
|
740
935
|
return Array.from(this.proposals.values());
|
|
741
936
|
}
|
|
742
937
|
|
|
@@ -802,7 +997,10 @@ export class Multisig {
|
|
|
802
997
|
}
|
|
803
998
|
|
|
804
999
|
/**
|
|
805
|
-
*
|
|
1000
|
+
* Returns the proposals cached by the most recent {@link syncProposals}
|
|
1001
|
+
* call, plus any locally created or imported proposals GUARDIAN has not
|
|
1002
|
+
* reported yet (see {@link syncProposals} for their retention). Not a
|
|
1003
|
+
* durable history: proposals GUARDIAN no longer reports were pruned.
|
|
806
1004
|
*/
|
|
807
1005
|
listProposals(): Proposal[] {
|
|
808
1006
|
return Array.from(this.proposals.values());
|
|
@@ -831,6 +1029,7 @@ export class Multisig {
|
|
|
831
1029
|
const proposal = this.proposalFactory().fromDelta(response.delta, response.commitment, metadata);
|
|
832
1030
|
await this.verifyProposalMetadataBinding(proposal);
|
|
833
1031
|
this.proposals.set(proposal.id, proposal);
|
|
1032
|
+
this.guardianKnownProposalIds.add(proposal.id);
|
|
834
1033
|
|
|
835
1034
|
return proposal;
|
|
836
1035
|
}
|
|
@@ -851,13 +1050,21 @@ export class Multisig {
|
|
|
851
1050
|
const webClient = await this.getRawClient();
|
|
852
1051
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
853
1052
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
1053
|
+
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
1054
|
+
// procedure asserts on every transaction after the update, checked before
|
|
1055
|
+
// any signature is collected.
|
|
1056
|
+
validateMultisigConfig({
|
|
1057
|
+
threshold: targetThreshold,
|
|
1058
|
+
signerCommitments: targetSignerCommitments,
|
|
1059
|
+
guardianCommitment: this.guardianCommitment,
|
|
1060
|
+
});
|
|
854
1061
|
this.warnOnOverrideDilution(targetSignerCommitments.length);
|
|
855
1062
|
|
|
856
1063
|
const { request, salt } = await buildUpdateSignersTransactionRequest(
|
|
857
1064
|
webClient,
|
|
858
1065
|
targetThreshold,
|
|
859
1066
|
targetSignerCommitments,
|
|
860
|
-
|
|
1067
|
+
this.proposalRequestOptions(options),
|
|
861
1068
|
);
|
|
862
1069
|
|
|
863
1070
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -916,7 +1123,7 @@ export class Multisig {
|
|
|
916
1123
|
webClient,
|
|
917
1124
|
targetThreshold,
|
|
918
1125
|
targetSignerCommitments,
|
|
919
|
-
|
|
1126
|
+
this.proposalRequestOptions(options),
|
|
920
1127
|
);
|
|
921
1128
|
|
|
922
1129
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -963,7 +1170,7 @@ export class Multisig {
|
|
|
963
1170
|
webClient,
|
|
964
1171
|
newThreshold,
|
|
965
1172
|
this.signerCommitments,
|
|
966
|
-
|
|
1173
|
+
this.proposalRequestOptions(options),
|
|
967
1174
|
);
|
|
968
1175
|
|
|
969
1176
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1012,7 +1219,7 @@ export class Multisig {
|
|
|
1012
1219
|
webClient,
|
|
1013
1220
|
targetProcedure,
|
|
1014
1221
|
targetThreshold,
|
|
1015
|
-
|
|
1222
|
+
this.proposalRequestOptions(options),
|
|
1016
1223
|
);
|
|
1017
1224
|
|
|
1018
1225
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1052,6 +1259,7 @@ export class Multisig {
|
|
|
1052
1259
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1053
1260
|
newGuardianEndpoint,
|
|
1054
1261
|
newGuardianPubkey,
|
|
1262
|
+
options.approvalExpirationDelta,
|
|
1055
1263
|
);
|
|
1056
1264
|
|
|
1057
1265
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
@@ -1069,14 +1277,26 @@ export class Multisig {
|
|
|
1069
1277
|
private async buildSwitchGuardianSummary(
|
|
1070
1278
|
newGuardianEndpoint: string,
|
|
1071
1279
|
newGuardianPubkey: string,
|
|
1280
|
+
approvalExpirationDelta: number | undefined,
|
|
1072
1281
|
): Promise<{ summaryBase64: string; metadata: ProposalMetadata }> {
|
|
1073
1282
|
const webClient = await this.getRawClient();
|
|
1283
|
+
// What `auth_tx_guarded_multisig` asserts after a guardian rotation, checked
|
|
1284
|
+
// before any signature is collected.
|
|
1285
|
+
validateMultisigConfig({
|
|
1286
|
+
threshold: this.threshold,
|
|
1287
|
+
signerCommitments: [...this.signerCommitments],
|
|
1288
|
+
guardianCommitment: newGuardianPubkey,
|
|
1289
|
+
});
|
|
1074
1290
|
await this.verifyGuardianEndpointCommitment(newGuardianEndpoint, newGuardianPubkey);
|
|
1075
1291
|
|
|
1076
1292
|
const { request, salt } = await buildUpdateGuardianTransactionRequest(
|
|
1077
1293
|
webClient,
|
|
1078
1294
|
newGuardianPubkey,
|
|
1079
|
-
{
|
|
1295
|
+
{
|
|
1296
|
+
accountId: this._accountId,
|
|
1297
|
+
approvalExpirationDelta,
|
|
1298
|
+
signatureScheme: this.signer.scheme,
|
|
1299
|
+
},
|
|
1080
1300
|
);
|
|
1081
1301
|
|
|
1082
1302
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -1139,6 +1359,7 @@ export class Multisig {
|
|
|
1139
1359
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1140
1360
|
newGuardianEndpoint,
|
|
1141
1361
|
newGuardianPubkey,
|
|
1362
|
+
options.approvalExpirationDelta,
|
|
1142
1363
|
);
|
|
1143
1364
|
|
|
1144
1365
|
const exported: ExportedProposal = {
|
|
@@ -1184,9 +1405,17 @@ export class Multisig {
|
|
|
1184
1405
|
}
|
|
1185
1406
|
fetchedNotes.push(inputNoteRecord.toNote());
|
|
1186
1407
|
}
|
|
1408
|
+
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
1409
|
+
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
1410
|
+
// the notes are authenticated here first, before the anchor is captured.
|
|
1411
|
+
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
1187
1412
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
1188
1413
|
|
|
1189
|
-
const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(
|
|
1414
|
+
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
|
|
1415
|
+
webClient,
|
|
1416
|
+
fetchedNotes,
|
|
1417
|
+
this.proposalRequestOptions(options),
|
|
1418
|
+
);
|
|
1190
1419
|
|
|
1191
1420
|
const { summary, anchor } = await executeForSummary(webClient, this._accountId, request);
|
|
1192
1421
|
const chainAnchor = chainAnchorToBase64(anchor);
|
|
@@ -1243,7 +1472,8 @@ export class Multisig {
|
|
|
1243
1472
|
// Forward everything but the nonce, so a note option added to
|
|
1244
1473
|
// CreateP2idProposalOptions can't be silently dropped before the builder.
|
|
1245
1474
|
const { nonce: _nonce, ...noteOptions } = options;
|
|
1246
|
-
const { request, salt } = buildP2idTransactionRequest(
|
|
1475
|
+
const { request, salt } = await buildP2idTransactionRequest(
|
|
1476
|
+
webClient,
|
|
1247
1477
|
this._accountId,
|
|
1248
1478
|
recipientId,
|
|
1249
1479
|
faucetId,
|
|
@@ -1441,6 +1671,18 @@ export class Multisig {
|
|
|
1441
1671
|
* store, reusing this client's Miden RPC endpoint and retry
|
|
1442
1672
|
* configuration.
|
|
1443
1673
|
*/
|
|
1674
|
+
/**
|
|
1675
|
+
* Puts the local store in the canonical (authenticated) consumption mode
|
|
1676
|
+
* for `notes`, fetching missing inclusion proofs from this client's Miden
|
|
1677
|
+
* node; see {@link ensureNotesAuthenticated}.
|
|
1678
|
+
*/
|
|
1679
|
+
private async ensureNotesAuthenticated(notes: readonly Note[]): Promise<void> {
|
|
1680
|
+
await ensureNotesAuthenticated(this.midenClient, notes, {
|
|
1681
|
+
midenRpcEndpoint: this.getMidenRpcEndpoint(),
|
|
1682
|
+
rpc: { retry: { maxAttempts: this.rpcConfig.maxAttempts } },
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1444
1686
|
private async importNotesFromProposals(
|
|
1445
1687
|
proposals: ReadonlyArray<Pick<Proposal, 'id' | 'metadata'>>,
|
|
1446
1688
|
cancelled?: () => boolean,
|
|
@@ -1742,6 +1984,8 @@ export class Multisig {
|
|
|
1742
1984
|
|
|
1743
1985
|
async signProposal(proposalId: string): Promise<Proposal> {
|
|
1744
1986
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1987
|
+
// Verification re-executes the proposal at the store's sync height.
|
|
1988
|
+
await this.syncChain();
|
|
1745
1989
|
const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
|
|
1746
1990
|
if (!existingProposal) {
|
|
1747
1991
|
throw new Error(`Proposal not found: ${proposalId}`);
|
|
@@ -1771,6 +2015,7 @@ export class Multisig {
|
|
|
1771
2015
|
await this.verifyProposalMetadataBinding(signedProposal);
|
|
1772
2016
|
|
|
1773
2017
|
this.proposals.set(signedProposal.id, signedProposal);
|
|
2018
|
+
this.guardianKnownProposalIds.add(signedProposal.id);
|
|
1774
2019
|
|
|
1775
2020
|
return signedProposal;
|
|
1776
2021
|
}
|
|
@@ -1788,6 +2033,12 @@ export class Multisig {
|
|
|
1788
2033
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
1789
2034
|
}
|
|
1790
2035
|
|
|
2036
|
+
/**
|
|
2037
|
+
* Builds the final, fully signed request for a ready proposal, for a caller
|
|
2038
|
+
* that proves and submits it with its own pipeline. The request declares the
|
|
2039
|
+
* block its summary binds, so execute it at the chain tip, without an
|
|
2040
|
+
* anchor, on a client synced to at least that block.
|
|
2041
|
+
*/
|
|
1791
2042
|
async createTransactionProposalRequest(proposalId: string): Promise<TransactionRequest> {
|
|
1792
2043
|
const { finalRequest } = await this.prepareProposalExecution(proposalId);
|
|
1793
2044
|
return finalRequest;
|
|
@@ -1808,16 +2059,9 @@ export class Multisig {
|
|
|
1808
2059
|
await this.preservePreSwitchProposalNotes();
|
|
1809
2060
|
}
|
|
1810
2061
|
|
|
1811
|
-
// Execute at the
|
|
1812
|
-
//
|
|
1813
|
-
|
|
1814
|
-
const accountId = AccountId.fromHex(this._accountId);
|
|
1815
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
1816
|
-
try {
|
|
1817
|
-
await this.proverWorkflow.submitAt(accountId, finalRequest, anchor);
|
|
1818
|
-
} finally {
|
|
1819
|
-
anchor.free();
|
|
1820
|
-
}
|
|
2062
|
+
// Execute at the chain tip. The request declares the block the signed
|
|
2063
|
+
// summary binds, so the summary the cosigners signed reproduces there.
|
|
2064
|
+
await this.submitAtTip(finalRequest);
|
|
1821
2065
|
|
|
1822
2066
|
if (metadata.proposalType === 'switch_guardian') {
|
|
1823
2067
|
if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
|
|
@@ -1871,18 +2115,19 @@ export class Multisig {
|
|
|
1871
2115
|
}
|
|
1872
2116
|
}
|
|
1873
2117
|
|
|
1874
|
-
proposal.status
|
|
2118
|
+
this.proposals.set(proposal.id, { ...proposal, status: 'finalized' });
|
|
1875
2119
|
}
|
|
1876
2120
|
|
|
1877
2121
|
/**
|
|
1878
2122
|
* Submit an integration-built transaction (advice already injected). Mirrors
|
|
1879
2123
|
* the Rust `submit_transaction`; used by the custom proposal producer flow
|
|
1880
2124
|
* after `prepareCustomExecution` rebuilds its request with the returned advice.
|
|
1881
|
-
* The transaction is executed at the
|
|
1882
|
-
*
|
|
2125
|
+
* The transaction is executed at the chain tip, so the request has to declare
|
|
2126
|
+
* the block its auth args bind (`feeAwareTransactionRequestBuilder` does).
|
|
1883
2127
|
*/
|
|
1884
2128
|
async submitTransaction(proposalId: string, request: TransactionRequest): Promise<void> {
|
|
1885
2129
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
2130
|
+
await this.syncChain();
|
|
1886
2131
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1887
2132
|
const existing = this.getLocalProposal(proposalId);
|
|
1888
2133
|
const proposal = this.proposalFactory().fromDelta(
|
|
@@ -1904,11 +2149,40 @@ export class Multisig {
|
|
|
1904
2149
|
`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
|
|
1905
2150
|
);
|
|
1906
2151
|
}
|
|
1907
|
-
|
|
1908
|
-
await this.proverWorkflow.submitAt(AccountId.fromHex(this._accountId), request, anchor);
|
|
1909
2152
|
} finally {
|
|
1910
2153
|
anchor.free();
|
|
1911
2154
|
}
|
|
2155
|
+
await this.submitAtTip(request);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Executes `request` at the chain tip, then proves, submits and applies it,
|
|
2160
|
+
* syncing first when this client is still below the block the request binds.
|
|
2161
|
+
*/
|
|
2162
|
+
private async submitAtTip(request: TransactionRequest): Promise<void> {
|
|
2163
|
+
const webClient = await this.getRawClient();
|
|
2164
|
+
await prepareTipExecution(webClient, request, () =>
|
|
2165
|
+
retryRpcRead(() => webClient.syncState(), this.rpcConfig),
|
|
2166
|
+
);
|
|
2167
|
+
await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Brings the Miden client to the chain tip before a proposal is re-executed:
|
|
2172
|
+
* an execution loads foreign accounts, the fee faucet among them, at the
|
|
2173
|
+
* store's sync height, which a node prunes about 50 blocks later.
|
|
2174
|
+
*/
|
|
2175
|
+
private async syncChain(): Promise<void> {
|
|
2176
|
+
const webClient = await this.getRawClient();
|
|
2177
|
+
await retryRpcRead(() => webClient.syncState(), this.rpcConfig);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** {@link syncToBoundBlock} with this client's RPC retry policy. */
|
|
2181
|
+
private async syncToBoundBlock(boundBlockNum: number): Promise<void> {
|
|
2182
|
+
const webClient = await this.getRawClient();
|
|
2183
|
+
await syncToBoundBlock(webClient, boundBlockNum, () =>
|
|
2184
|
+
retryRpcRead(() => webClient.syncState(), this.rpcConfig),
|
|
2185
|
+
);
|
|
1912
2186
|
}
|
|
1913
2187
|
|
|
1914
2188
|
/**
|
|
@@ -1972,6 +2246,8 @@ export class Multisig {
|
|
|
1972
2246
|
transactionRequestBytes: Uint8Array,
|
|
1973
2247
|
): Promise<AdviceMap> {
|
|
1974
2248
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
2249
|
+
// The binding probe re-executes the producer's request at the store's sync height.
|
|
2250
|
+
await this.syncChain();
|
|
1975
2251
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1976
2252
|
const existing = this.getLocalProposal(proposalId);
|
|
1977
2253
|
const proposal = this.proposalFactory().fromDelta(
|
|
@@ -2006,13 +2282,11 @@ export class Multisig {
|
|
|
2006
2282
|
|
|
2007
2283
|
const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
|
|
2008
2284
|
|
|
2009
|
-
//
|
|
2010
|
-
//
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
// before executing against it.
|
|
2285
|
+
// The anchor arrives from an untrusted party via GUARDIAN, so its block
|
|
2286
|
+
// commitment is checked against the signed summary: it has to name the
|
|
2287
|
+
// block the summary binds. The probe itself runs at the chain tip, where
|
|
2288
|
+
// the request's declared bound block reproduces the signed summary.
|
|
2014
2289
|
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
2015
|
-
let derivedCommitmentHex: string;
|
|
2016
2290
|
try {
|
|
2017
2291
|
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
2018
2292
|
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
@@ -2021,18 +2295,20 @@ export class Multisig {
|
|
|
2021
2295
|
`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
|
|
2022
2296
|
);
|
|
2023
2297
|
}
|
|
2024
|
-
const webClient = await this.getRawClient();
|
|
2025
|
-
const derived = await executeForSummaryAt(webClient, this._accountId, bindingRequest, anchor);
|
|
2026
|
-
derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
2027
2298
|
} finally {
|
|
2028
2299
|
anchor.free();
|
|
2029
2300
|
}
|
|
2301
|
+
const webClient = await this.getRawClient();
|
|
2302
|
+
const derived = await executeForSummaryAtTip(webClient, this._accountId, bindingRequest);
|
|
2303
|
+
const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
2030
2304
|
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
2031
2305
|
throw new Error(
|
|
2032
2306
|
`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
|
|
2033
2307
|
);
|
|
2034
2308
|
}
|
|
2035
2309
|
|
|
2310
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
2311
|
+
|
|
2036
2312
|
return this.assembleCustomAdvice(
|
|
2037
2313
|
proposalId,
|
|
2038
2314
|
signaturesForExecution,
|
|
@@ -2041,6 +2317,34 @@ export class Multisig {
|
|
|
2041
2317
|
);
|
|
2042
2318
|
}
|
|
2043
2319
|
|
|
2320
|
+
private buildCosignerAdviceEntry(
|
|
2321
|
+
cosignerSig: ProposalSignatureEntry,
|
|
2322
|
+
signerCommitment: Word,
|
|
2323
|
+
txCommitmentHex: string,
|
|
2324
|
+
): { key: Word; values: Felt[] } {
|
|
2325
|
+
const approval = cosignerSig.signature;
|
|
2326
|
+
const txCommitment = Word.fromHex(txCommitmentHex);
|
|
2327
|
+
if (approval.scheme === 'ecdsa' && approval.messageFormat === 'eip712') {
|
|
2328
|
+
if (!approval.publicKey) {
|
|
2329
|
+
throw new Error(`ECDSA proposal signature for ${cosignerSig.signerId} is missing publicKey`);
|
|
2330
|
+
}
|
|
2331
|
+
return buildEip712SignatureAdviceEntry(
|
|
2332
|
+
signerCommitment,
|
|
2333
|
+
txCommitment,
|
|
2334
|
+
approval.signature,
|
|
2335
|
+
approval.publicKey,
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const signature = Signature.deserialize(
|
|
2340
|
+
signatureHexToBytes(approval.signature, approval.scheme),
|
|
2341
|
+
);
|
|
2342
|
+
if (approval.scheme === 'ecdsa' && approval.publicKey) {
|
|
2343
|
+
assertEcdsaSignatureRecoverable(approval.signature, txCommitmentHex, approval.publicKey);
|
|
2344
|
+
}
|
|
2345
|
+
return buildSignatureAdviceEntry(signerCommitment, txCommitment, signature);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2044
2348
|
private async assembleCustomAdvice(
|
|
2045
2349
|
proposalId: string,
|
|
2046
2350
|
signaturesForExecution: ProposalSignatureEntry[],
|
|
@@ -2077,22 +2381,8 @@ export class Multisig {
|
|
|
2077
2381
|
}
|
|
2078
2382
|
|
|
2079
2383
|
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,
|
|
2384
|
+
const { key, values } = this.buildCosignerAdviceEntry(
|
|
2385
|
+
cosignerSig, signerCommitment, normalizedTxCommitmentHex,
|
|
2096
2386
|
);
|
|
2097
2387
|
const keyHex = normalizeHexWord(key.toHex());
|
|
2098
2388
|
if (adviceMapKeys.has(keyHex)) {
|
|
@@ -2148,15 +2438,23 @@ export class Multisig {
|
|
|
2148
2438
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
2149
2439
|
}
|
|
2150
2440
|
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2441
|
+
/**
|
|
2442
|
+
* The returned request declares the block the signed summary binds, and
|
|
2443
|
+
* executes at the chain tip.
|
|
2444
|
+
*/
|
|
2445
|
+
private async prepareProposalExecution(proposalId: string): Promise<{
|
|
2446
|
+
finalRequest: TransactionRequest;
|
|
2447
|
+
metadata: ProposalMetadata;
|
|
2448
|
+
proposal: Proposal;
|
|
2449
|
+
}> {
|
|
2154
2450
|
const proposal = this.getLocalProposal(proposalId);
|
|
2155
2451
|
if (!proposal) {
|
|
2156
2452
|
throw new Error(`Proposal not found: ${proposalId}`);
|
|
2157
2453
|
}
|
|
2158
2454
|
|
|
2159
2455
|
this.proposalFactory().assertAccountId(proposal.accountId);
|
|
2456
|
+
// Verification and execution run at the store's sync height.
|
|
2457
|
+
await this.syncChain();
|
|
2160
2458
|
await this.verifyProposalMetadataBinding(proposal);
|
|
2161
2459
|
|
|
2162
2460
|
const metadata = proposal.metadata;
|
|
@@ -2213,6 +2511,8 @@ export class Multisig {
|
|
|
2213
2511
|
);
|
|
2214
2512
|
}
|
|
2215
2513
|
|
|
2514
|
+
await this.assertApprovalNotExpired(proposalId, txSummary);
|
|
2515
|
+
|
|
2216
2516
|
const normalizedSignerCommitments = new Set(
|
|
2217
2517
|
this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
|
|
2218
2518
|
);
|
|
@@ -2246,22 +2546,8 @@ export class Multisig {
|
|
|
2246
2546
|
}
|
|
2247
2547
|
|
|
2248
2548
|
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,
|
|
2549
|
+
const { key, values } = this.buildCosignerAdviceEntry(
|
|
2550
|
+
cosignerSig, signerCommitment, normalizedTxCommitmentHex,
|
|
2265
2551
|
);
|
|
2266
2552
|
const keyHex = normalizeHexWord(key.toHex());
|
|
2267
2553
|
if (adviceMapKeys.has(keyHex)) {
|
|
@@ -2317,16 +2603,21 @@ export class Multisig {
|
|
|
2317
2603
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
2318
2604
|
}
|
|
2319
2605
|
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
const executionSalt = Word.fromHex(normalizeHexWord(saltHex));
|
|
2323
|
-
let finalRequest;
|
|
2606
|
+
const anchor = this.requireProposalAnchor(proposalId, metadata);
|
|
2607
|
+
let binding: ProposalRequestBinding;
|
|
2324
2608
|
try {
|
|
2325
|
-
|
|
2609
|
+
binding = proposalRequestBinding(txSummary, anchor, saltHex);
|
|
2326
2610
|
} finally {
|
|
2327
|
-
|
|
2611
|
+
anchor.free();
|
|
2328
2612
|
}
|
|
2329
|
-
|
|
2613
|
+
// A switch_guardian proposal is verified without a rebuild, so this may be
|
|
2614
|
+
// the first time this client needs the chain at the bound block.
|
|
2615
|
+
await this.syncToBoundBlock(binding.boundBlockNum);
|
|
2616
|
+
const finalRequest = await this.buildTransactionRequestFromMetadata(
|
|
2617
|
+
metadata,
|
|
2618
|
+
binding,
|
|
2619
|
+
adviceMap,
|
|
2620
|
+
);
|
|
2330
2621
|
return { finalRequest, metadata, proposal };
|
|
2331
2622
|
}
|
|
2332
2623
|
|
|
@@ -2350,6 +2641,8 @@ export class Multisig {
|
|
|
2350
2641
|
signatureHex: s.signature.signature,
|
|
2351
2642
|
scheme: s.signature.scheme,
|
|
2352
2643
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
2644
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
2645
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
2353
2646
|
timestamp: s.timestamp,
|
|
2354
2647
|
}))
|
|
2355
2648
|
: [];
|
|
@@ -2386,6 +2679,8 @@ export class Multisig {
|
|
|
2386
2679
|
signatureHex: s.signature.signature,
|
|
2387
2680
|
scheme: s.signature.scheme,
|
|
2388
2681
|
publicKey: s.signature.scheme === 'ecdsa' ? s.signature.publicKey : undefined,
|
|
2682
|
+
...(s.signature.scheme === 'ecdsa' && s.signature.messageFormat
|
|
2683
|
+
? { messageFormat: s.signature.messageFormat } : {}),
|
|
2389
2684
|
timestamp: s.timestamp,
|
|
2390
2685
|
})),
|
|
2391
2686
|
metadata: proposal.metadata,
|
|
@@ -2408,6 +2703,10 @@ export class Multisig {
|
|
|
2408
2703
|
|
|
2409
2704
|
const proposal = this.proposalFactory().fromExported(exported);
|
|
2410
2705
|
|
|
2706
|
+
// Verification re-executes every built-in type at the store's sync height.
|
|
2707
|
+
if (proposal.metadata.proposalType !== 'custom') {
|
|
2708
|
+
await this.syncChain();
|
|
2709
|
+
}
|
|
2411
2710
|
await this.verifyProposalMetadataBinding(proposal);
|
|
2412
2711
|
this.proposals.set(proposal.id, proposal);
|
|
2413
2712
|
|
|
@@ -2448,6 +2747,10 @@ export class Multisig {
|
|
|
2448
2747
|
throw new Error('You have already signed this proposal');
|
|
2449
2748
|
}
|
|
2450
2749
|
|
|
2750
|
+
// Verification re-executes every built-in type at the store's sync height.
|
|
2751
|
+
if (proposal.metadata?.proposalType !== 'custom') {
|
|
2752
|
+
await this.syncChain();
|
|
2753
|
+
}
|
|
2451
2754
|
const commitmentToSign = await this.verifyProposalMetadataBinding(proposal);
|
|
2452
2755
|
|
|
2453
2756
|
// Sign the commitment
|
|
@@ -2467,14 +2770,17 @@ export class Multisig {
|
|
|
2467
2770
|
this.signerCommitments,
|
|
2468
2771
|
localSignatureContext,
|
|
2469
2772
|
).entries();
|
|
2470
|
-
proposal.signatures = canonicalizedSignatures;
|
|
2471
|
-
|
|
2472
|
-
// Update status
|
|
2473
2773
|
const proposalType = proposal.metadata?.proposalType;
|
|
2474
2774
|
const signaturesRequired = proposalType
|
|
2475
2775
|
? this.getEffectiveThreshold(proposalType)
|
|
2476
2776
|
: this.threshold;
|
|
2477
|
-
|
|
2777
|
+
// A fresh object rather than an in-place write: a sync that snapshotted
|
|
2778
|
+
// the cache before this signature tells the two apart by identity.
|
|
2779
|
+
this.proposals.set(proposal.id, {
|
|
2780
|
+
...proposal,
|
|
2781
|
+
signatures: canonicalizedSignatures,
|
|
2782
|
+
status: canonicalizedSignatures.length >= signaturesRequired ? 'ready' : 'pending',
|
|
2783
|
+
});
|
|
2478
2784
|
|
|
2479
2785
|
// Return updated JSON
|
|
2480
2786
|
return this.exportProposalToJson(proposal.id);
|
|
@@ -2493,15 +2799,38 @@ export class Multisig {
|
|
|
2493
2799
|
return txSummaryCommitment;
|
|
2494
2800
|
}
|
|
2495
2801
|
|
|
2802
|
+
/**
|
|
2803
|
+
* Verifies that a proposal's metadata reconstructs its signed summary
|
|
2804
|
+
* commitment and records the outcome in {@link Proposal.verification}:
|
|
2805
|
+
* `verified`, or `failed` with the message and whether the failure looked
|
|
2806
|
+
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2807
|
+
* while `syncProposals` keeps going with the outcome recorded.
|
|
2808
|
+
*/
|
|
2496
2809
|
private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
|
|
2810
|
+
try {
|
|
2811
|
+
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2812
|
+
proposal.verification = { status: 'verified' };
|
|
2813
|
+
return commitment;
|
|
2814
|
+
} catch (error) {
|
|
2815
|
+
proposal.verification = {
|
|
2816
|
+
status: 'failed',
|
|
2817
|
+
retryable: isTransientRpcError(error) || isStaleChainError(error),
|
|
2818
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2819
|
+
};
|
|
2820
|
+
throw error;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
private async checkProposalMetadataBinding(proposal: Proposal): Promise<string> {
|
|
2497
2825
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
2498
2826
|
|
|
2499
2827
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
2500
2828
|
|
|
2501
|
-
// The anchor arrives from an untrusted party via GUARDIAN, so check
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2829
|
+
// The anchor arrives from an untrusted party via GUARDIAN, so check that it
|
|
2830
|
+
// names the block the signed summary binds: the rebuild below binds the
|
|
2831
|
+
// block it names. Nothing executes against it; the rebuild runs at the
|
|
2832
|
+
// chain tip. `ChainAnchor.deserialize` already enforced internal
|
|
2833
|
+
// header/chain consistency.
|
|
2505
2834
|
const anchor = this.requireProposalAnchor(proposal.id, proposal.metadata);
|
|
2506
2835
|
try {
|
|
2507
2836
|
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
@@ -2519,19 +2848,47 @@ export class Multisig {
|
|
|
2519
2848
|
return txSummaryCommitment;
|
|
2520
2849
|
}
|
|
2521
2850
|
|
|
2851
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2852
|
+
// type, switch_guardian included (as in the Rust SDK): a mismatched salt
|
|
2853
|
+
// would otherwise collect signatures and only fail in the VM.
|
|
2854
|
+
const binding = proposalRequestBinding(
|
|
2855
|
+
summary,
|
|
2856
|
+
anchor,
|
|
2857
|
+
this.requireProposalSaltHex(proposal.id, proposal.metadata),
|
|
2858
|
+
);
|
|
2859
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2860
|
+
throw new Error(
|
|
2861
|
+
`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
|
|
2862
|
+
);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2522
2865
|
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
2523
|
-
// Re-execution would mutate the WASM account twice. The proposal ID
|
|
2524
|
-
//
|
|
2866
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2867
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2868
|
+
// binding checks for this type.
|
|
2525
2869
|
return txSummaryCommitment;
|
|
2526
2870
|
}
|
|
2527
2871
|
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2872
|
+
// The rebuild reads the chain's fee faucet from the synced protocol
|
|
2873
|
+
// configuration and executes at the tip, so the store has to have synced
|
|
2874
|
+
// to the block the summary binds; a cosigner that has only just loaded
|
|
2875
|
+
// the account has not.
|
|
2876
|
+
await this.syncToBoundBlock(binding.boundBlockNum);
|
|
2533
2877
|
const webClient = await this.getRawClient();
|
|
2534
|
-
|
|
2878
|
+
|
|
2879
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2880
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2881
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2882
|
+
// that never held these notes reproduces a different commitment.
|
|
2883
|
+
if (
|
|
2884
|
+
proposal.metadata.proposalType === 'consume_notes' &&
|
|
2885
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
|
|
2886
|
+
) {
|
|
2887
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2891
|
+
const reconstructed = await executeForSummaryAtTip(webClient, this._accountId, request);
|
|
2535
2892
|
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
2536
2893
|
|
|
2537
2894
|
if (reconstructedCommitment !== txSummaryCommitment) {
|
|
@@ -2544,34 +2901,23 @@ export class Multisig {
|
|
|
2544
2901
|
}
|
|
2545
2902
|
}
|
|
2546
2903
|
|
|
2547
|
-
/**
|
|
2548
|
-
* Decodes a proposal's chain anchor. Throws when absent: a proposal without
|
|
2549
|
-
* an anchor was created at an unknown reference block, so its signed summary
|
|
2550
|
-
* cannot be reproduced, verified, or executed. The caller owns the returned
|
|
2551
|
-
* anchor and must `free()` it once done.
|
|
2552
|
-
*/
|
|
2553
2904
|
/**
|
|
2554
2905
|
* Reads a proposal's salt. Throws when absent, because there is nothing to fall
|
|
2555
2906
|
* back to.
|
|
2556
2907
|
*
|
|
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.
|
|
2908
|
+
* The salt goes into the request's multisig auth args and the summary binds it in
|
|
2909
|
+
* its user params, so `summarySalt(summary)` reads the value the cosigners signed
|
|
2910
|
+
* over. It is not a substitute for this field: a request has to be rebuilt before
|
|
2911
|
+
* any summary exists, and a proposal GUARDIAN serves may pair a summary with
|
|
2912
|
+
* metadata that names another salt, which the binding check reports by name.
|
|
2566
2913
|
*/
|
|
2567
2914
|
private requireProposalSaltHex(proposalId: string, metadata: ProposalMetadata): string {
|
|
2568
2915
|
const saltHex: unknown = metadata.saltHex;
|
|
2569
2916
|
|
|
2570
2917
|
if (saltHex === undefined || saltHex === null || saltHex === '') {
|
|
2571
2918
|
throw new Error(
|
|
2572
|
-
`Proposal ${proposalId} has no salt; its request cannot be rebuilt
|
|
2573
|
-
'
|
|
2574
|
-
'to the salt',
|
|
2919
|
+
`Proposal ${proposalId} has no salt; its request cannot be rebuilt without the ` +
|
|
2920
|
+
'salt its auth args and signed summary bind',
|
|
2575
2921
|
);
|
|
2576
2922
|
}
|
|
2577
2923
|
|
|
@@ -2603,24 +2949,76 @@ export class Multisig {
|
|
|
2603
2949
|
return saltHex;
|
|
2604
2950
|
}
|
|
2605
2951
|
|
|
2952
|
+
/**
|
|
2953
|
+
* Decodes a proposal's chain anchor, which names the block its signed summary
|
|
2954
|
+
* binds. Throws when absent: every proposal carries one, so a proposal
|
|
2955
|
+
* without it is malformed and is neither verified nor executed. The caller
|
|
2956
|
+
* owns the returned anchor and must `free()` it once done.
|
|
2957
|
+
*/
|
|
2606
2958
|
private requireProposalAnchor(proposalId: string, metadata: ProposalMetadata): ChainAnchor {
|
|
2607
2959
|
if (!metadata.chainAnchor) {
|
|
2608
2960
|
throw new Error(
|
|
2609
|
-
`Proposal ${proposalId} has no chain anchor
|
|
2610
|
-
'
|
|
2611
|
-
'at the original reference block',
|
|
2961
|
+
`Proposal ${proposalId} has no chain anchor, which names the block its signed ` +
|
|
2962
|
+
'summary binds; it cannot be verified or executed',
|
|
2612
2963
|
);
|
|
2613
2964
|
}
|
|
2614
2965
|
return chainAnchorFromBase64(metadata.chainAnchor);
|
|
2615
2966
|
}
|
|
2616
2967
|
|
|
2968
|
+
/**
|
|
2969
|
+
* An expired approval aborts in the auth procedure only at execution.
|
|
2970
|
+
* The summary carries the deadline, so callers check it against the sync
|
|
2971
|
+
* height before assembling advice or requesting the GUARDIAN ack.
|
|
2972
|
+
*/
|
|
2973
|
+
private async assertApprovalNotExpired(
|
|
2974
|
+
proposalId: string,
|
|
2975
|
+
summary: TransactionSummary,
|
|
2976
|
+
): Promise<void> {
|
|
2977
|
+
const expirationBlockNum = summaryApprovalExpirationBlockNum(summary);
|
|
2978
|
+
if (expirationBlockNum === undefined) {
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const webClient = await this.getRawClient();
|
|
2982
|
+
const syncHeight = await webClient.getSyncHeight();
|
|
2983
|
+
if (syncHeight >= expirationBlockNum) {
|
|
2984
|
+
throw new Error(
|
|
2985
|
+
`Proposal ${proposalId} approval expired at block ${expirationBlockNum}; the chain is at ` +
|
|
2986
|
+
`block ${syncHeight}, so the collected signatures no longer authorize it`,
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* Rebuilds a proposal's request from its metadata under `binding`, so the
|
|
2993
|
+
* summary it produces is the one the cosigners signed.
|
|
2994
|
+
*/
|
|
2617
2995
|
private async buildTransactionRequestFromMetadata(
|
|
2618
2996
|
metadata: ProposalMetadata,
|
|
2619
|
-
|
|
2997
|
+
binding: ProposalRequestBinding,
|
|
2620
2998
|
signatureAdviceMap?: AdviceMap,
|
|
2621
2999
|
): Promise<TransactionRequest> {
|
|
2622
|
-
|
|
3000
|
+
// The builders read `.toHex()` and allocate their own Word, so this handle
|
|
3001
|
+
// stays ours; without the release it leaks once per rebuild.
|
|
3002
|
+
const salt = Word.fromHex(binding.saltHex);
|
|
3003
|
+
try {
|
|
3004
|
+
return await this.buildTransactionRequestWithOptions(metadata, {
|
|
3005
|
+
accountId: this._accountId,
|
|
3006
|
+
boundBlockNum: binding.boundBlockNum,
|
|
3007
|
+
approvalExpirationDelta: binding.approvalExpirationDelta,
|
|
3008
|
+
salt,
|
|
3009
|
+
signatureAdviceMap,
|
|
3010
|
+
signatureScheme: this.signer.scheme,
|
|
3011
|
+
});
|
|
3012
|
+
} finally {
|
|
3013
|
+
salt.free?.();
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
2623
3016
|
|
|
3017
|
+
private async buildTransactionRequestWithOptions(
|
|
3018
|
+
metadata: ProposalMetadata,
|
|
3019
|
+
requestOptions: MultisigRequestOptions,
|
|
3020
|
+
): Promise<TransactionRequest> {
|
|
3021
|
+
const webClient = await this.getRawClient();
|
|
2624
3022
|
switch (metadata.proposalType) {
|
|
2625
3023
|
case 'add_signer':
|
|
2626
3024
|
case 'remove_signer':
|
|
@@ -2629,7 +3027,7 @@ export class Multisig {
|
|
|
2629
3027
|
webClient,
|
|
2630
3028
|
metadata.targetThreshold,
|
|
2631
3029
|
metadata.targetSignerCommitments,
|
|
2632
|
-
|
|
3030
|
+
requestOptions,
|
|
2633
3031
|
);
|
|
2634
3032
|
return request;
|
|
2635
3033
|
}
|
|
@@ -2637,7 +3035,7 @@ export class Multisig {
|
|
|
2637
3035
|
const { request } = await buildUpdateGuardianTransactionRequest(
|
|
2638
3036
|
webClient,
|
|
2639
3037
|
metadata.newGuardianPubkey,
|
|
2640
|
-
|
|
3038
|
+
requestOptions,
|
|
2641
3039
|
);
|
|
2642
3040
|
return request;
|
|
2643
3041
|
}
|
|
@@ -2646,7 +3044,7 @@ export class Multisig {
|
|
|
2646
3044
|
webClient,
|
|
2647
3045
|
metadata.targetProcedure,
|
|
2648
3046
|
metadata.targetThreshold,
|
|
2649
|
-
|
|
3047
|
+
requestOptions,
|
|
2650
3048
|
);
|
|
2651
3049
|
return request;
|
|
2652
3050
|
}
|
|
@@ -2654,29 +3052,12 @@ export class Multisig {
|
|
|
2654
3052
|
// v1/v2 dispatch for issue #229 / FR-009.
|
|
2655
3053
|
const version = metadata.metadataVersion;
|
|
2656
3054
|
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
|
-
});
|
|
3055
|
+
const decoded = decodeEmbeddedConsumeNotes(metadata);
|
|
3056
|
+
const { request } = await buildConsumeNotesTransactionRequestFromNotes(
|
|
3057
|
+
webClient,
|
|
3058
|
+
decoded,
|
|
3059
|
+
requestOptions,
|
|
3060
|
+
);
|
|
2680
3061
|
return request;
|
|
2681
3062
|
}
|
|
2682
3063
|
if (version === undefined || version === 1) {
|
|
@@ -2688,25 +3069,25 @@ export class Multisig {
|
|
|
2688
3069
|
const { request } = await buildConsumeNotesTransactionRequest(
|
|
2689
3070
|
webClient,
|
|
2690
3071
|
metadata.noteIds,
|
|
2691
|
-
|
|
3072
|
+
requestOptions,
|
|
2692
3073
|
);
|
|
2693
3074
|
return request;
|
|
2694
3075
|
}
|
|
2695
3076
|
throw new UnsupportedMetadataVersionError(version);
|
|
2696
3077
|
}
|
|
2697
3078
|
case 'p2id': {
|
|
2698
|
-
const { request } = buildP2idTransactionRequest(
|
|
3079
|
+
const { request } = await buildP2idTransactionRequest(
|
|
3080
|
+
webClient,
|
|
2699
3081
|
this._accountId,
|
|
2700
3082
|
metadata.recipientId,
|
|
2701
3083
|
metadata.faucetId,
|
|
2702
3084
|
BigInt(metadata.amount),
|
|
2703
3085
|
{
|
|
2704
|
-
|
|
2705
|
-
signatureAdviceMap,
|
|
3086
|
+
...requestOptions,
|
|
2706
3087
|
noteType: parseP2idNoteType(metadata.noteType),
|
|
2707
3088
|
reclaimHeight: metadata.reclaimHeight,
|
|
2708
3089
|
timelockHeight: metadata.timelockHeight,
|
|
2709
|
-
}
|
|
3090
|
+
},
|
|
2710
3091
|
);
|
|
2711
3092
|
return request;
|
|
2712
3093
|
}
|
|
@@ -2718,3 +3099,31 @@ export class Multisig {
|
|
|
2718
3099
|
}
|
|
2719
3100
|
|
|
2720
3101
|
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* Decodes a v2 `consume_notes` proposal's embedded notes, asserting each one
|
|
3105
|
+
* is the note its declared id names (spec 006 FR-007).
|
|
3106
|
+
*/
|
|
3107
|
+
function decodeEmbeddedConsumeNotes(metadata: ConsumeNotesProposalMetadata): Note[] {
|
|
3108
|
+
const embedded = metadata.notes ?? [];
|
|
3109
|
+
const noteIds = metadata.noteIds ?? [];
|
|
3110
|
+
if (embedded.length !== noteIds.length) {
|
|
3111
|
+
throw new NoteBindingMismatchError(
|
|
3112
|
+
`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${noteIds.length}`,
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
const decoded: Note[] = [];
|
|
3116
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
3117
|
+
const note = noteFromBase64(embedded[i], Note);
|
|
3118
|
+
// Normalize both sides; matches the file's other hex comparisons.
|
|
3119
|
+
const embeddedId = normalizeHexWord(note.id().toString());
|
|
3120
|
+
const declaredId = normalizeHexWord(noteIds[i]);
|
|
3121
|
+
if (embeddedId !== declaredId) {
|
|
3122
|
+
throw new NoteBindingMismatchError(
|
|
3123
|
+
`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
decoded.push(note);
|
|
3127
|
+
}
|
|
3128
|
+
return decoded;
|
|
3129
|
+
}
|