@openzeppelin/miden-multisig-client 0.18.0-rc.3 → 0.18.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 +117 -25
- package/dist/client.d.ts +39 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +77 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts.map +1 -1
- package/dist/lookupAuth.js +2 -20
- package/dist/lookupAuth.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +27 -1
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +33 -0
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig.d.ts +77 -11
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +247 -181
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +2 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/recovery/transportDrain.d.ts +38 -30
- package/dist/recovery/transportDrain.d.ts.map +1 -1
- package/dist/recovery/transportDrain.js +113 -113
- package/dist/recovery/transportDrain.js.map +1 -1
- package/dist/session/grant.d.ts +40 -0
- package/dist/session/grant.d.ts.map +1 -0
- package/dist/session/grant.js +125 -0
- package/dist/session/grant.js.map +1 -0
- package/dist/session/key.d.ts +103 -0
- package/dist/session/key.d.ts.map +1 -0
- package/dist/session/key.js +160 -0
- package/dist/session/key.js.map +1 -0
- package/dist/session/manager.d.ts +76 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -1
- package/dist/signers/ecdsa.d.ts.map +1 -1
- package/dist/signers/ecdsa.js +9 -0
- package/dist/signers/ecdsa.js.map +1 -1
- package/dist/signers/falcon.d.ts +5 -1
- package/dist/signers/falcon.d.ts.map +1 -1
- package/dist/signers/falcon.js +9 -0
- package/dist/signers/falcon.js.map +1 -1
- package/dist/signers/ledger.d.ts +9 -1
- package/dist/signers/ledger.d.ts.map +1 -1
- package/dist/signers/ledger.js +13 -1
- package/dist/signers/ledger.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +13 -1
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +25 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/para.d.ts +5 -1
- package/dist/signers/para.d.ts.map +1 -1
- package/dist/signers/para.js +9 -0
- package/dist/signers/para.js.map +1 -1
- package/dist/transaction/authArgs.d.ts +2 -2
- package/dist/transaction/authArgs.js +2 -2
- package/dist/transaction/options.d.ts +2 -2
- package/dist/transaction/summary.d.ts +17 -61
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +49 -95
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +11 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +26 -69
- package/dist/utils/eip712.d.ts.map +1 -1
- package/dist/utils/eip712.js +101 -8
- package/dist/utils/eip712.js.map +1 -1
- package/dist/utils/felt.d.ts +7 -1
- package/dist/utils/felt.d.ts.map +1 -1
- package/dist/utils/felt.js +18 -1
- package/dist/utils/felt.js.map +1 -1
- package/package.json +4 -4
- package/src/client.ts +95 -0
- package/src/index.ts +27 -6
- package/src/lookupAuth.ts +3 -22
- package/src/multisig/authArgErrors.ts +42 -1
- package/src/multisig.ts +318 -207
- package/src/proposal/metadata.ts +2 -0
- package/src/recovery/transportDrain.ts +118 -124
- package/src/session/grant.ts +143 -0
- package/src/session/key.ts +215 -0
- package/src/session/manager.ts +273 -0
- package/src/signers/ecdsa.ts +12 -1
- package/src/signers/falcon.ts +12 -1
- package/src/signers/ledger.ts +19 -2
- package/src/signers/miden-wallet.ts +29 -1
- package/src/signers/para.ts +12 -1
- package/src/transaction/authArgs.ts +2 -2
- package/src/transaction/options.ts +2 -2
- package/src/transaction/summary.ts +62 -110
- package/src/transaction.ts +1 -6
- package/src/types/proposal.ts +11 -5
- package/src/utils/eip712.ts +130 -10
- package/src/utils/felt.ts +19 -1
package/src/multisig.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* for proposal management.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type HistoryOptions, type HistoryPage, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
|
|
8
|
+
import { GuardianHttpClient, GuardianHttpError, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type HistoryOptions, type HistoryPage, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
|
|
9
9
|
import type {
|
|
10
10
|
ConsumableNote,
|
|
11
11
|
ExportedProposal,
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
ProposalSignatureEntry,
|
|
17
17
|
ProposalType,
|
|
18
18
|
} from './types.js';
|
|
19
|
-
import { ProposalSaltMalformedError } from './multisig/authArgErrors.js';
|
|
19
|
+
import { BoundBlockMismatchError, ProposalSaltMalformedError } from './multisig/authArgErrors.js';
|
|
20
20
|
import type { ProcedureName } from './procedures.js';
|
|
21
21
|
import type { MidenClient, OutputNoteRecord } from '@miden-sdk/miden-sdk';
|
|
22
22
|
import {
|
|
@@ -35,17 +35,14 @@ import {
|
|
|
35
35
|
TransactionRequest,
|
|
36
36
|
TransactionSummary,
|
|
37
37
|
Word,
|
|
38
|
-
type ChainAnchor,
|
|
39
38
|
} from '@miden-sdk/miden-sdk';
|
|
40
39
|
import {
|
|
41
|
-
chainAnchorFromBase64,
|
|
42
|
-
chainAnchorToBase64,
|
|
43
|
-
executeForSummary,
|
|
44
40
|
executeForSummaryAtTip,
|
|
45
41
|
isStaleChainError,
|
|
46
42
|
prepareTipExecution,
|
|
47
43
|
syncToBoundBlock,
|
|
48
44
|
summaryApprovalExpirationBlockNum,
|
|
45
|
+
summaryBoundBlockNum,
|
|
49
46
|
summarySalt,
|
|
50
47
|
buildUpdateSignersTransactionRequest,
|
|
51
48
|
buildUpdateProcedureThresholdTransactionRequest,
|
|
@@ -164,7 +161,15 @@ export interface AccountStateVerificationResult {
|
|
|
164
161
|
* positional `undefined` holes to reach a later option.
|
|
165
162
|
*/
|
|
166
163
|
export interface CreateProposalOptions {
|
|
167
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Proposal nonce: the account nonce the executed transaction will have.
|
|
166
|
+
* Defaults to the store account's nonce plus one, as the Rust SDK labels
|
|
167
|
+
* proposals. GUARDIAN keys the proposal and its delta by it, orders
|
|
168
|
+
* history and the candidate queue by it, and when it queues chained
|
|
169
|
+
* candidates (issue #17) records a proposal only at the queue tail's
|
|
170
|
+
* nonce plus one. Pass it only to label from another record of the
|
|
171
|
+
* account's nonce. Through 0.18.0 the default was `Date.now()`.
|
|
172
|
+
*/
|
|
168
173
|
nonce?: number;
|
|
169
174
|
/**
|
|
170
175
|
* Blocks after the block the proposal binds by which the transaction must be
|
|
@@ -218,29 +223,27 @@ function deserializeTransactionRequest(bytes: Uint8Array): TransactionRequest {
|
|
|
218
223
|
}
|
|
219
224
|
|
|
220
225
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* fail loudly instead.
|
|
226
|
+
* Runtime guard for pre-#387 positional callers. Untyped JS passing the old
|
|
227
|
+
* `nonce` number (or a legacy trailing argument) would otherwise bind it as
|
|
228
|
+
* the options bag and silently fall back to every default — a public note
|
|
229
|
+
* instead of a private one, or the current threshold instead of the
|
|
230
|
+
* requested one — so it must fail loudly instead.
|
|
227
231
|
*/
|
|
228
|
-
function
|
|
232
|
+
function assertProposalOptionsBag(
|
|
229
233
|
method: string,
|
|
230
234
|
options: CreateProposalOptions,
|
|
231
235
|
legacyArgs: readonly unknown[] = [],
|
|
232
|
-
):
|
|
236
|
+
): void {
|
|
233
237
|
if (typeof options !== 'object' || options === null || legacyArgs.length > 0) {
|
|
234
238
|
throw new Error(
|
|
235
239
|
`${method}: positional optional parameters were replaced by a trailing options object (issue #387); pass { nonce, ... } instead`,
|
|
236
240
|
);
|
|
237
241
|
}
|
|
238
|
-
return options.nonce ?? Date.now();
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
/**
|
|
242
245
|
* What a rebuild of a proposal's request pins so the signed summary
|
|
243
|
-
* reproduces: the salt, the block its
|
|
246
|
+
* reproduces: the salt, the block its summary binds, and the approval
|
|
244
247
|
* expiration the summary binds, as the delta the builders take.
|
|
245
248
|
*/
|
|
246
249
|
interface ProposalRequestBinding {
|
|
@@ -251,10 +254,9 @@ interface ProposalRequestBinding {
|
|
|
251
254
|
|
|
252
255
|
function proposalRequestBinding(
|
|
253
256
|
summary: TransactionSummary,
|
|
254
|
-
|
|
257
|
+
boundBlockNum: number,
|
|
255
258
|
saltHex: string,
|
|
256
259
|
): ProposalRequestBinding {
|
|
257
|
-
const boundBlockNum = anchor.blockNum();
|
|
258
260
|
return {
|
|
259
261
|
saltHex: normalizeHexWord(saltHex),
|
|
260
262
|
boundBlockNum,
|
|
@@ -316,6 +318,9 @@ const PRE_SWITCH_SETTLE_GRACE_MS = 5_000;
|
|
|
316
318
|
/** A `Word` is four field elements: 64 hex digits. Anything longer is not a salt. */
|
|
317
319
|
const MAX_SALT_HEX_DIGITS = 64;
|
|
318
320
|
|
|
321
|
+
/** Block numbers are `u32` on chain. */
|
|
322
|
+
const MAX_BLOCK_NUM = 0xffff_ffff;
|
|
323
|
+
|
|
319
324
|
/**
|
|
320
325
|
* Consecutive successful listings that must omit a guardian-known proposal
|
|
321
326
|
* not yet listed (a fresh create during read-your-writes lag, or one
|
|
@@ -444,6 +449,67 @@ export class Multisig {
|
|
|
444
449
|
return retryRpcRead(() => this.midenClient.accounts.get(this._accountId), this.rpcConfig);
|
|
445
450
|
}
|
|
446
451
|
|
|
452
|
+
/**
|
|
453
|
+
* The nonce a proposal is labelled with: `options.nonce`, or the store
|
|
454
|
+
* account's nonce plus one, the nonce the executed transaction will
|
|
455
|
+
* have. That is what the Rust SDK labels with, what the stale-proposal
|
|
456
|
+
* filters compare with the account nonce, and what GUARDIAN's candidate
|
|
457
|
+
* queue requires of a proposal built on its tail (issue #17). Through
|
|
458
|
+
* 0.18.0 the default was `Date.now()`, a key no admission rule
|
|
459
|
+
* could relate to the account's state. The label is read after any sync
|
|
460
|
+
* the method performs (note authentication, the offline switch's node
|
|
461
|
+
* sync), on the account the summary is then built from; a caller that
|
|
462
|
+
* has just read that account passes it to save the store read.
|
|
463
|
+
*/
|
|
464
|
+
private async resolveProposalNonce(
|
|
465
|
+
method: string,
|
|
466
|
+
options: CreateProposalOptions,
|
|
467
|
+
legacyArgs: readonly unknown[] = [],
|
|
468
|
+
account?: Account | null,
|
|
469
|
+
): Promise<number> {
|
|
470
|
+
assertProposalOptionsBag(method, options, legacyArgs);
|
|
471
|
+
if (options.nonce !== undefined && options.nonce !== null) {
|
|
472
|
+
return options.nonce;
|
|
473
|
+
}
|
|
474
|
+
const labelled = account ?? (await this.getStoreAccount());
|
|
475
|
+
const next = labelled.nonce().asInt() + 1n;
|
|
476
|
+
if (next > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`${method}: the account nonce ${next - 1n} is too large to label a proposal with as a number; pass options.nonce`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return Number(next);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Refuses to execute a proposal GUARDIAN pinned to a state other than the
|
|
486
|
+
* one this client holds. The transaction executes on this client's own
|
|
487
|
+
* record of the account while the execution push names the pinned base,
|
|
488
|
+
* so the two have to be the same state. A proposal pinned to a state the
|
|
489
|
+
* account has since left was made for that state, and its transaction
|
|
490
|
+
* would otherwise run again on the current one. One pinned to a state this
|
|
491
|
+
* client does not hold (a server that queues chained candidates pins a
|
|
492
|
+
* proposal to the newest queued candidate's post-state) would be admitted
|
|
493
|
+
* behind that candidate while the transaction itself lands on the older
|
|
494
|
+
* state, leaving GUARDIAN behind the chain.
|
|
495
|
+
*/
|
|
496
|
+
private async assertExecutesOnPinnedBase(proposalId: string, pinnedBase: string): Promise<string> {
|
|
497
|
+
const local = await this.localAccountCommitment();
|
|
498
|
+
const pinned = normalizeHexWord(pinnedBase);
|
|
499
|
+
if (local !== pinned) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`Proposal ${proposalId} was made for account state ${pinned}, but this client's account is at ${local}: ` +
|
|
502
|
+
'sync the account (syncState) and retry, or create a new proposal if the account has moved past that state',
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
return local;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The commitment of this client's record of the account. */
|
|
509
|
+
private async localAccountCommitment(): Promise<string> {
|
|
510
|
+
return normalizeHexWord((await this.getStoreAccount()).to_commitment().toHex());
|
|
511
|
+
}
|
|
512
|
+
|
|
447
513
|
/**
|
|
448
514
|
* Read the current ordered signer public-key commitments from account
|
|
449
515
|
* storage (store-backed state, falling back to the snapshot).
|
|
@@ -552,6 +618,20 @@ export class Multisig {
|
|
|
552
618
|
};
|
|
553
619
|
}
|
|
554
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Derives a new proposal's summary at the chain tip, with the block it binds
|
|
623
|
+
* for the proposal's `boundBlockNum`.
|
|
624
|
+
*/
|
|
625
|
+
private async deriveProposalSummary(
|
|
626
|
+
request: TransactionRequest,
|
|
627
|
+
): Promise<{ summaryBase64: string; boundBlockNum: number }> {
|
|
628
|
+
const summary = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
|
|
629
|
+
return {
|
|
630
|
+
summaryBase64: uint8ArrayToBase64(summary.serialize()),
|
|
631
|
+
boundBlockNum: summaryBoundBlockNum(summary),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
555
635
|
private warnOnOverrideDilution(newNumSigners: number): void {
|
|
556
636
|
const current = this.signerCommitments.length;
|
|
557
637
|
for (const { procedure, threshold } of this.overridesDilutedBySignerGrowth(newNumSigners)) {
|
|
@@ -859,10 +939,11 @@ export class Multisig {
|
|
|
859
939
|
* without applying its listing.
|
|
860
940
|
*
|
|
861
941
|
* Nonce-based staleness hiding is the caller's job (see the examples'
|
|
862
|
-
* `filterVisibleProposals`)
|
|
863
|
-
*
|
|
864
|
-
* nonce
|
|
865
|
-
*
|
|
942
|
+
* `filterVisibleProposals`). This SDK labels a proposal with the account's
|
|
943
|
+
* next nonce by default, as the Rust SDK does, but a caller may label with
|
|
944
|
+
* its own `nonce` (and proposals made through 0.18.0 carry a
|
|
945
|
+
* timestamp), so the Rust client's `proposal.nonce <= account.nonce()`
|
|
946
|
+
* filter is not applied here. This is an intentional TS/Rust surface
|
|
866
947
|
* difference.
|
|
867
948
|
*/
|
|
868
949
|
syncProposals(): Promise<Proposal[]> {
|
|
@@ -905,8 +986,12 @@ export class Multisig {
|
|
|
905
986
|
existingProposal?.signatures ?? [],
|
|
906
987
|
);
|
|
907
988
|
// The outcome lands on the proposal either way; a failure is reported
|
|
908
|
-
// there rather than failing the sync.
|
|
909
|
-
|
|
989
|
+
// there rather than failing the sync. The chain was synced once above,
|
|
990
|
+
// so a bound block past that height stays a retryable failure until the
|
|
991
|
+
// next listing instead of costing a sync per proposal.
|
|
992
|
+
await this.verifyProposalMetadataBinding(proposal, async () => undefined).catch(
|
|
993
|
+
() => undefined,
|
|
994
|
+
);
|
|
910
995
|
reported.set(proposal.id, { delta, verified: proposal });
|
|
911
996
|
}
|
|
912
997
|
|
|
@@ -990,6 +1075,11 @@ export class Multisig {
|
|
|
990
1075
|
currentNonce = undefined;
|
|
991
1076
|
}
|
|
992
1077
|
|
|
1078
|
+
// At most one chain sync for the whole listing, however many proposals
|
|
1079
|
+
// bind a block above the store's sync height.
|
|
1080
|
+
let listingSync: Promise<void> | undefined;
|
|
1081
|
+
const syncOnce = () => (listingSync ??= this.syncChain());
|
|
1082
|
+
|
|
993
1083
|
const proposals: Proposal[] = [];
|
|
994
1084
|
const skipped: Array<{ identifier: string; reason: string }> = [];
|
|
995
1085
|
for (let position = 0; position < deltas.length; position += 1) {
|
|
@@ -1013,7 +1103,7 @@ export class Multisig {
|
|
|
1013
1103
|
if (currentNonce !== undefined && BigInt(proposal.nonce) <= currentNonce) {
|
|
1014
1104
|
continue;
|
|
1015
1105
|
}
|
|
1016
|
-
await this.verifyProposalMetadataBinding(proposal);
|
|
1106
|
+
await this.verifyProposalMetadataBinding(proposal, syncOnce);
|
|
1017
1107
|
proposals.push(proposal);
|
|
1018
1108
|
} catch (error) {
|
|
1019
1109
|
skipped.push({
|
|
@@ -1075,7 +1165,7 @@ export class Multisig {
|
|
|
1075
1165
|
options: CreateSignerProposalOptions = {},
|
|
1076
1166
|
...legacyArgs: never[]
|
|
1077
1167
|
): Promise<Proposal> {
|
|
1078
|
-
|
|
1168
|
+
assertProposalOptionsBag('createAddSignerProposal', options, legacyArgs);
|
|
1079
1169
|
const targetThreshold = options.newThreshold ?? this.threshold;
|
|
1080
1170
|
const targetSignerCommitments = [...this.signerCommitments, newCommitment];
|
|
1081
1171
|
// What `update_signers_and_threshold` rejects on-chain, and what the auth
|
|
@@ -1095,13 +1185,11 @@ export class Multisig {
|
|
|
1095
1185
|
this.proposalRequestOptions(options),
|
|
1096
1186
|
);
|
|
1097
1187
|
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
anchor.free();
|
|
1101
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1188
|
+
const proposalNonce = await this.resolveProposalNonce('createAddSignerProposal', options);
|
|
1189
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1102
1190
|
|
|
1103
1191
|
const metadata: ProposalMetadata = {
|
|
1104
|
-
|
|
1192
|
+
boundBlockNum,
|
|
1105
1193
|
proposalType: 'add_signer',
|
|
1106
1194
|
targetThreshold,
|
|
1107
1195
|
targetSignerCommitments,
|
|
@@ -1125,7 +1213,7 @@ export class Multisig {
|
|
|
1125
1213
|
options: CreateSignerProposalOptions = {},
|
|
1126
1214
|
...legacyArgs: never[]
|
|
1127
1215
|
): Promise<Proposal> {
|
|
1128
|
-
|
|
1216
|
+
assertProposalOptionsBag('createRemoveSignerProposal', options, legacyArgs);
|
|
1129
1217
|
const normalizedRemove = signerToRemove.toLowerCase();
|
|
1130
1218
|
const targetSignerCommitments = this.signerCommitments.filter(
|
|
1131
1219
|
(c) => c.toLowerCase() !== normalizedRemove
|
|
@@ -1153,13 +1241,11 @@ export class Multisig {
|
|
|
1153
1241
|
this.proposalRequestOptions(options),
|
|
1154
1242
|
);
|
|
1155
1243
|
|
|
1156
|
-
const
|
|
1157
|
-
const
|
|
1158
|
-
anchor.free();
|
|
1159
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1244
|
+
const proposalNonce = await this.resolveProposalNonce('createRemoveSignerProposal', options);
|
|
1245
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1160
1246
|
|
|
1161
1247
|
const metadata: ProposalMetadata = {
|
|
1162
|
-
|
|
1248
|
+
boundBlockNum,
|
|
1163
1249
|
proposalType: 'remove_signer',
|
|
1164
1250
|
targetThreshold,
|
|
1165
1251
|
targetSignerCommitments,
|
|
@@ -1181,7 +1267,7 @@ export class Multisig {
|
|
|
1181
1267
|
newThreshold: number,
|
|
1182
1268
|
options: CreateProposalOptions = {},
|
|
1183
1269
|
): Promise<Proposal> {
|
|
1184
|
-
|
|
1270
|
+
assertProposalOptionsBag('createChangeThresholdProposal', options);
|
|
1185
1271
|
if (newThreshold < 1 || newThreshold > this.signerCommitments.length) {
|
|
1186
1272
|
throw new Error(
|
|
1187
1273
|
`Invalid threshold ${newThreshold}. Must be between 1 and ${this.signerCommitments.length}`
|
|
@@ -1199,13 +1285,11 @@ export class Multisig {
|
|
|
1199
1285
|
this.proposalRequestOptions(options),
|
|
1200
1286
|
);
|
|
1201
1287
|
|
|
1202
|
-
const
|
|
1203
|
-
const
|
|
1204
|
-
anchor.free();
|
|
1205
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1288
|
+
const proposalNonce = await this.resolveProposalNonce('createChangeThresholdProposal', options);
|
|
1289
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1206
1290
|
|
|
1207
1291
|
const metadata: ProposalMetadata = {
|
|
1208
|
-
|
|
1292
|
+
boundBlockNum,
|
|
1209
1293
|
proposalType: 'change_threshold',
|
|
1210
1294
|
targetThreshold: newThreshold,
|
|
1211
1295
|
targetSignerCommitments: this.signerCommitments,
|
|
@@ -1222,7 +1306,7 @@ export class Multisig {
|
|
|
1222
1306
|
targetThreshold: number,
|
|
1223
1307
|
options: CreateProposalOptions = {},
|
|
1224
1308
|
): Promise<Proposal> {
|
|
1225
|
-
|
|
1309
|
+
assertProposalOptionsBag('createUpdateProcedureThresholdProposal', options);
|
|
1226
1310
|
if (targetThreshold < 0 || targetThreshold > this.signerCommitments.length) {
|
|
1227
1311
|
throw new Error(
|
|
1228
1312
|
`Invalid threshold ${targetThreshold}. Must be between 0 and ${this.signerCommitments.length}`
|
|
@@ -1247,16 +1331,14 @@ export class Multisig {
|
|
|
1247
1331
|
this.proposalRequestOptions(options),
|
|
1248
1332
|
);
|
|
1249
1333
|
|
|
1250
|
-
const
|
|
1251
|
-
const
|
|
1252
|
-
anchor.free();
|
|
1253
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1334
|
+
const proposalNonce = await this.resolveProposalNonce('createUpdateProcedureThresholdProposal', options);
|
|
1335
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1254
1336
|
const action = targetThreshold === 0
|
|
1255
1337
|
? `Clear threshold override for ${targetProcedure}`
|
|
1256
1338
|
: `Set ${targetProcedure} threshold override to ${targetThreshold}`;
|
|
1257
1339
|
|
|
1258
1340
|
const metadata: ProposalMetadata = {
|
|
1259
|
-
|
|
1341
|
+
boundBlockNum,
|
|
1260
1342
|
proposalType: 'update_procedure_threshold',
|
|
1261
1343
|
targetProcedure,
|
|
1262
1344
|
targetThreshold,
|
|
@@ -1280,7 +1362,7 @@ export class Multisig {
|
|
|
1280
1362
|
newGuardianPubkey: string,
|
|
1281
1363
|
options: CreateProposalOptions = {},
|
|
1282
1364
|
): Promise<Proposal> {
|
|
1283
|
-
|
|
1365
|
+
assertProposalOptionsBag('createSwitchGuardianProposal', options);
|
|
1284
1366
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1285
1367
|
newGuardianEndpoint,
|
|
1286
1368
|
newGuardianPubkey,
|
|
@@ -1290,6 +1372,7 @@ export class Multisig {
|
|
|
1290
1372
|
// SwitchGuardian is a regular delta proposal; push it to GUARDIAN so
|
|
1291
1373
|
// sign/execute (which fetch from GUARDIAN) can find it. To leave an
|
|
1292
1374
|
// unreachable GUARDIAN, use createSwitchGuardianProposalOffline instead.
|
|
1375
|
+
const proposalNonce = await this.resolveProposalNonce('createSwitchGuardianProposal', options);
|
|
1293
1376
|
return this.createProposal(proposalNonce, summaryBase64, metadata);
|
|
1294
1377
|
}
|
|
1295
1378
|
|
|
@@ -1323,13 +1406,10 @@ export class Multisig {
|
|
|
1323
1406
|
},
|
|
1324
1407
|
);
|
|
1325
1408
|
|
|
1326
|
-
const {
|
|
1327
|
-
const chainAnchor = chainAnchorToBase64(anchor);
|
|
1328
|
-
anchor.free();
|
|
1329
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1409
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1330
1410
|
|
|
1331
1411
|
const metadata: ProposalMetadata = {
|
|
1332
|
-
|
|
1412
|
+
boundBlockNum,
|
|
1333
1413
|
proposalType: 'switch_guardian',
|
|
1334
1414
|
saltHex: salt.toHex(),
|
|
1335
1415
|
requiredSignatures: this.getEffectiveThreshold('switch_guardian'),
|
|
@@ -1371,14 +1451,20 @@ export class Multisig {
|
|
|
1371
1451
|
newGuardianPubkey: string,
|
|
1372
1452
|
options: CreateProposalOptions = {},
|
|
1373
1453
|
): Promise<ExportedProposal> {
|
|
1374
|
-
|
|
1454
|
+
assertProposalOptionsBag('createSwitchGuardianProposalOffline', options);
|
|
1375
1455
|
|
|
1376
1456
|
// Sync with the Miden node and refresh the cached account/config before
|
|
1377
1457
|
// building (mirrors the Rust `sync_network_only`): with no GUARDIAN push
|
|
1378
1458
|
// to reject a stale delta at creation, a summary built from stale local
|
|
1379
1459
|
// state — or a readiness threshold read from stale config — would only
|
|
1380
1460
|
// fail at execution, after the whole side-channel cosigning ceremony.
|
|
1381
|
-
await this.syncNetworkOnly();
|
|
1461
|
+
const synced = await this.syncNetworkOnly();
|
|
1462
|
+
const proposalNonce = await this.resolveProposalNonce(
|
|
1463
|
+
'createSwitchGuardianProposalOffline',
|
|
1464
|
+
options,
|
|
1465
|
+
[],
|
|
1466
|
+
synced,
|
|
1467
|
+
);
|
|
1382
1468
|
|
|
1383
1469
|
const { summaryBase64, metadata } = await this.buildSwitchGuardianSummary(
|
|
1384
1470
|
newGuardianEndpoint,
|
|
@@ -1413,7 +1499,7 @@ export class Multisig {
|
|
|
1413
1499
|
noteIds: string[],
|
|
1414
1500
|
options: CreateProposalOptions = {},
|
|
1415
1501
|
): Promise<Proposal> {
|
|
1416
|
-
|
|
1502
|
+
assertProposalOptionsBag('createConsumeNotesProposal', options);
|
|
1417
1503
|
if (noteIds.length === 0) {
|
|
1418
1504
|
throw new Error('At least one note ID is required');
|
|
1419
1505
|
}
|
|
@@ -1429,8 +1515,9 @@ export class Multisig {
|
|
|
1429
1515
|
}
|
|
1430
1516
|
// Canonical consumption mode is authenticated (issue #409): the summary this
|
|
1431
1517
|
// proposal signs must be the one every cosigner's rebuild reproduces, so
|
|
1432
|
-
// the notes are authenticated here first, before the
|
|
1518
|
+
// the notes are authenticated here first, before the summary is derived.
|
|
1433
1519
|
await this.ensureNotesAuthenticated(fetchedNotes);
|
|
1520
|
+
const proposalNonce = await this.resolveProposalNonce('createConsumeNotesProposal', options);
|
|
1434
1521
|
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
1435
1522
|
|
|
1436
1523
|
const { request, salt } = await buildConsumeNotesTransactionRequestFromNotes(
|
|
@@ -1439,13 +1526,10 @@ export class Multisig {
|
|
|
1439
1526
|
this.proposalRequestOptions(options),
|
|
1440
1527
|
);
|
|
1441
1528
|
|
|
1442
|
-
const {
|
|
1443
|
-
const chainAnchor = chainAnchorToBase64(anchor);
|
|
1444
|
-
anchor.free();
|
|
1445
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1529
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1446
1530
|
|
|
1447
1531
|
const metadata: ProposalMetadata = {
|
|
1448
|
-
|
|
1532
|
+
boundBlockNum,
|
|
1449
1533
|
proposalType: 'consume_notes',
|
|
1450
1534
|
noteIds,
|
|
1451
1535
|
metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
|
|
@@ -1485,7 +1569,7 @@ export class Multisig {
|
|
|
1485
1569
|
options: CreateP2idProposalOptions = {},
|
|
1486
1570
|
...legacyArgs: never[]
|
|
1487
1571
|
): Promise<Proposal> {
|
|
1488
|
-
|
|
1572
|
+
assertProposalOptionsBag('createP2idProposal', options, legacyArgs);
|
|
1489
1573
|
if (amount <= 0n) {
|
|
1490
1574
|
throw new Error('Amount must be greater than 0');
|
|
1491
1575
|
}
|
|
@@ -1502,13 +1586,11 @@ export class Multisig {
|
|
|
1502
1586
|
noteOptions,
|
|
1503
1587
|
);
|
|
1504
1588
|
|
|
1505
|
-
const
|
|
1506
|
-
const
|
|
1507
|
-
anchor.free();
|
|
1508
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1589
|
+
const proposalNonce = await this.resolveProposalNonce('createP2idProposal', options);
|
|
1590
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
1509
1591
|
|
|
1510
1592
|
const metadata: ProposalMetadata = {
|
|
1511
|
-
|
|
1593
|
+
boundBlockNum,
|
|
1512
1594
|
proposalType: 'p2id',
|
|
1513
1595
|
saltHex: salt.toHex(),
|
|
1514
1596
|
requiredSignatures: this.getEffectiveThreshold('p2id'),
|
|
@@ -2044,7 +2126,8 @@ export class Multisig {
|
|
|
2044
2126
|
* @param proposalId - The proposal commitment/ID
|
|
2045
2127
|
*/
|
|
2046
2128
|
async executeProposal(proposalId: string): Promise<void> {
|
|
2047
|
-
const { metadata, finalRequest, proposal } =
|
|
2129
|
+
const { metadata, finalRequest, proposal, executedBase } =
|
|
2130
|
+
await this.prepareProposalExecution(proposalId);
|
|
2048
2131
|
|
|
2049
2132
|
if (metadata.proposalType === 'switch_guardian') {
|
|
2050
2133
|
// #417: import notes embedded in pending proposals from the old
|
|
@@ -2065,16 +2148,16 @@ export class Multisig {
|
|
|
2065
2148
|
// Canonicalize the executed delta on the pre-switch GUARDIAN (clears the
|
|
2066
2149
|
// pending proposal). Must run before `this.guardian` is repointed below.
|
|
2067
2150
|
// Best-effort: an unreachable old GUARDIAN must not block the switch, so
|
|
2068
|
-
// errors are swallowed (mirrors the Rust execute path
|
|
2151
|
+
// errors are swallowed (mirrors the Rust execute path, which names the
|
|
2152
|
+
// state it executed on and sends the summary it holds; GUARDIAN assigns
|
|
2153
|
+
// the status).
|
|
2069
2154
|
try {
|
|
2070
|
-
const normalizedProposalId = normalizeHexWord(proposal.id);
|
|
2071
|
-
const switchDelta = await this.guardian.getDeltaProposal(
|
|
2072
|
-
this._accountId,
|
|
2073
|
-
normalizedProposalId,
|
|
2074
|
-
);
|
|
2075
2155
|
await this.guardian.pushDelta({
|
|
2076
|
-
|
|
2077
|
-
|
|
2156
|
+
accountId: this._accountId,
|
|
2157
|
+
nonce: proposal.nonce,
|
|
2158
|
+
prevCommitment: executedBase,
|
|
2159
|
+
deltaPayload: { data: proposal.txSummary },
|
|
2160
|
+
status: { status: 'candidate', timestamp: new Date().toISOString() },
|
|
2078
2161
|
});
|
|
2079
2162
|
} catch (error) {
|
|
2080
2163
|
// Best-effort — see above — but the failure must be visible: a
|
|
@@ -2124,28 +2207,14 @@ export class Multisig {
|
|
|
2124
2207
|
await this.syncChain();
|
|
2125
2208
|
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
2126
2209
|
const existing = this.getLocalProposal(proposalId);
|
|
2127
|
-
|
|
2210
|
+
// Parsed for its id check: a proposal GUARDIAN does not serve, or whose id
|
|
2211
|
+
// is not its summary's commitment, is not submitted (as in the Rust SDK).
|
|
2212
|
+
this.proposalFactory().fromDelta(
|
|
2128
2213
|
delta,
|
|
2129
2214
|
normalizedProposalId,
|
|
2130
2215
|
existing?.metadata,
|
|
2131
2216
|
existing?.signatures ?? [],
|
|
2132
2217
|
);
|
|
2133
|
-
|
|
2134
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
2135
|
-
try {
|
|
2136
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
2137
|
-
const txSummary = TransactionSummary.deserialize(
|
|
2138
|
-
base64ToUint8Array(delta.deltaPayload.txSummary.data),
|
|
2139
|
-
);
|
|
2140
|
-
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
2141
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
2142
|
-
throw new Error(
|
|
2143
|
-
`Proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
|
|
2144
|
-
);
|
|
2145
|
-
}
|
|
2146
|
-
} finally {
|
|
2147
|
-
anchor.free();
|
|
2148
|
-
}
|
|
2149
2218
|
await this.submitAtTip(request);
|
|
2150
2219
|
}
|
|
2151
2220
|
|
|
@@ -2167,9 +2236,15 @@ export class Multisig {
|
|
|
2167
2236
|
await retryRpcRead(() => this.midenClient.syncChain(), this.rpcConfig);
|
|
2168
2237
|
}
|
|
2169
2238
|
|
|
2170
|
-
/**
|
|
2171
|
-
|
|
2172
|
-
|
|
2239
|
+
/**
|
|
2240
|
+
* {@link syncToBoundBlock} with this client's RPC retry policy. A listing
|
|
2241
|
+
* passes its own `syncChain` so it syncs at most once for all its proposals.
|
|
2242
|
+
*/
|
|
2243
|
+
private async syncToBoundBlock(
|
|
2244
|
+
boundBlockNum: number,
|
|
2245
|
+
syncChain: () => Promise<void> = () => this.syncChain(),
|
|
2246
|
+
): Promise<void> {
|
|
2247
|
+
await syncToBoundBlock(this.midenClient, boundBlockNum, syncChain);
|
|
2173
2248
|
}
|
|
2174
2249
|
|
|
2175
2250
|
/**
|
|
@@ -2184,7 +2259,7 @@ export class Multisig {
|
|
|
2184
2259
|
proposalType: string,
|
|
2185
2260
|
options: CreateProposalOptions = {},
|
|
2186
2261
|
): Promise<Proposal> {
|
|
2187
|
-
|
|
2262
|
+
assertProposalOptionsBag('createCustomProposal', options);
|
|
2188
2263
|
const label = proposalType.trim().toLowerCase();
|
|
2189
2264
|
if (label.length === 0) {
|
|
2190
2265
|
throw new Error('proposalType must not be empty');
|
|
@@ -2201,13 +2276,11 @@ export class Multisig {
|
|
|
2201
2276
|
}
|
|
2202
2277
|
|
|
2203
2278
|
const request = deserializeTransactionRequest(transactionRequestBytes);
|
|
2204
|
-
const
|
|
2205
|
-
const
|
|
2206
|
-
anchor.free();
|
|
2207
|
-
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
2279
|
+
const proposalNonce = await this.resolveProposalNonce('createCustomProposal', options);
|
|
2280
|
+
const { summaryBase64, boundBlockNum } = await this.deriveProposalSummary(request);
|
|
2208
2281
|
|
|
2209
2282
|
const metadata: ProposalMetadata = {
|
|
2210
|
-
|
|
2283
|
+
boundBlockNum,
|
|
2211
2284
|
proposalType: 'custom',
|
|
2212
2285
|
description: '',
|
|
2213
2286
|
rawProposalType: label,
|
|
@@ -2260,30 +2333,16 @@ export class Multisig {
|
|
|
2260
2333
|
`Proposal is not ready for execution: have ${signaturesForExecution.length} of ${effectiveThreshold} required signatures.`,
|
|
2261
2334
|
);
|
|
2262
2335
|
}
|
|
2336
|
+
await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment);
|
|
2263
2337
|
|
|
2264
2338
|
const txSummary = TransactionSummary.deserialize(
|
|
2265
2339
|
base64ToUint8Array(delta.deltaPayload.txSummary.data),
|
|
2266
2340
|
);
|
|
2341
|
+
this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, txSummary);
|
|
2267
2342
|
const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
|
|
2268
2343
|
|
|
2269
2344
|
const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
|
|
2270
2345
|
|
|
2271
|
-
// The anchor arrives from an untrusted party via GUARDIAN, so its block
|
|
2272
|
-
// commitment is checked against the signed summary: it has to name the
|
|
2273
|
-
// block the summary binds. The probe itself runs at the chain tip, where
|
|
2274
|
-
// the request's declared bound block reproduces the signed summary.
|
|
2275
|
-
const anchor = this.requireProposalAnchor(proposalId, proposal.metadata);
|
|
2276
|
-
try {
|
|
2277
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
2278
|
-
const summaryBlockCommitment = normalizeHexWord(txSummary.blockCommitment().toHex());
|
|
2279
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
2280
|
-
throw new Error(
|
|
2281
|
-
`Custom proposal ${proposalId} chain anchor does not match the block commitment bound into its tx_summary`,
|
|
2282
|
-
);
|
|
2283
|
-
}
|
|
2284
|
-
} finally {
|
|
2285
|
-
anchor.free();
|
|
2286
|
-
}
|
|
2287
2346
|
const derived = await executeForSummaryAtTip(this.midenClient, this._accountId, bindingRequest);
|
|
2288
2347
|
const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
2289
2348
|
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
@@ -2431,6 +2490,8 @@ export class Multisig {
|
|
|
2431
2490
|
finalRequest: TransactionRequest;
|
|
2432
2491
|
metadata: ProposalMetadata;
|
|
2433
2492
|
proposal: Proposal;
|
|
2493
|
+
/** The account state the request was built on: this client's record. */
|
|
2494
|
+
executedBase: string;
|
|
2434
2495
|
}> {
|
|
2435
2496
|
const proposal = this.getLocalProposal(proposalId);
|
|
2436
2497
|
if (!proposal) {
|
|
@@ -2472,10 +2533,14 @@ export class Multisig {
|
|
|
2472
2533
|
|
|
2473
2534
|
if (isSwitchGuardian) {
|
|
2474
2535
|
txSummaryBase64 = proposal.txSummary;
|
|
2536
|
+
delta = await this.servedSwitchProposal(normalizedProposalId);
|
|
2475
2537
|
} else {
|
|
2476
2538
|
delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
2477
2539
|
txSummaryBase64 = delta.deltaPayload.txSummary.data;
|
|
2478
2540
|
}
|
|
2541
|
+
const executedBase = delta
|
|
2542
|
+
? await this.assertExecutesOnPinnedBase(proposalId, delta.prevCommitment)
|
|
2543
|
+
: await this.localAccountCommitment();
|
|
2479
2544
|
|
|
2480
2545
|
const txSummaryBytes = base64ToUint8Array(txSummaryBase64);
|
|
2481
2546
|
const txSummary = TransactionSummary.deserialize(txSummaryBytes);
|
|
@@ -2588,13 +2653,11 @@ export class Multisig {
|
|
|
2588
2653
|
await this.verifyGuardianEndpointCommitment(metadata.newGuardianEndpoint, metadata.newGuardianPubkey);
|
|
2589
2654
|
}
|
|
2590
2655
|
|
|
2591
|
-
const
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
anchor.free();
|
|
2597
|
-
}
|
|
2656
|
+
const binding = proposalRequestBinding(
|
|
2657
|
+
txSummary,
|
|
2658
|
+
this.requireProposalBoundBlockNum(proposalId, metadata, txSummary),
|
|
2659
|
+
saltHex,
|
|
2660
|
+
);
|
|
2598
2661
|
// A switch_guardian proposal is verified without a rebuild, so this may be
|
|
2599
2662
|
// the first time this client needs the chain at the bound block.
|
|
2600
2663
|
await this.syncToBoundBlock(binding.boundBlockNum);
|
|
@@ -2603,7 +2666,35 @@ export class Multisig {
|
|
|
2603
2666
|
binding,
|
|
2604
2667
|
adviceMap,
|
|
2605
2668
|
);
|
|
2606
|
-
return { finalRequest, metadata, proposal };
|
|
2669
|
+
return { finalRequest, metadata, proposal, executedBase };
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/**
|
|
2673
|
+
* A switch_guardian proposal as the pre-switch GUARDIAN serves it, so the
|
|
2674
|
+
* state it pinned the proposal to is checked as for every other type, or
|
|
2675
|
+
* `undefined` when that GUARDIAN does not serve it. Not holding the
|
|
2676
|
+
* proposal (one made offline) is the designed offline case. Any other
|
|
2677
|
+
* failure, whether the GUARDIAN is unreachable or answers with an error
|
|
2678
|
+
* (an authentication failure, a paused or released account, rate limiting,
|
|
2679
|
+
* a server fault), is warned about. In every one of those cases the switch
|
|
2680
|
+
* then executes from its cached summary without the check: rotating away
|
|
2681
|
+
* from a GUARDIAN that is down or refusing service is what the switch
|
|
2682
|
+
* exists for, so that GUARDIAN cannot be what blocks it.
|
|
2683
|
+
*/
|
|
2684
|
+
private async servedSwitchProposal(normalizedProposalId: string): Promise<DeltaObject | undefined> {
|
|
2685
|
+
try {
|
|
2686
|
+
return await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
2687
|
+
} catch (error) {
|
|
2688
|
+
if (error instanceof GuardianHttpError && error.code === 'proposal_not_found') {
|
|
2689
|
+
return undefined;
|
|
2690
|
+
}
|
|
2691
|
+
console.warn(
|
|
2692
|
+
'The pre-switch GUARDIAN did not serve the switch proposal; executing it without ' +
|
|
2693
|
+
'checking the state it was pinned to',
|
|
2694
|
+
error,
|
|
2695
|
+
);
|
|
2696
|
+
return undefined;
|
|
2697
|
+
}
|
|
2607
2698
|
}
|
|
2608
2699
|
|
|
2609
2700
|
/**
|
|
@@ -2791,9 +2882,12 @@ export class Multisig {
|
|
|
2791
2882
|
* transient. Rethrows the failure so strict callers keep failing closed
|
|
2792
2883
|
* while `syncProposals` keeps going with the outcome recorded.
|
|
2793
2884
|
*/
|
|
2794
|
-
private async verifyProposalMetadataBinding(
|
|
2885
|
+
private async verifyProposalMetadataBinding(
|
|
2886
|
+
proposal: Proposal,
|
|
2887
|
+
syncChain?: () => Promise<void>,
|
|
2888
|
+
): Promise<string> {
|
|
2795
2889
|
try {
|
|
2796
|
-
const commitment = await this.checkProposalMetadataBinding(proposal);
|
|
2890
|
+
const commitment = await this.checkProposalMetadataBinding(proposal, syncChain);
|
|
2797
2891
|
proposal.verification = { status: 'verified' };
|
|
2798
2892
|
return commitment;
|
|
2799
2893
|
} catch (error) {
|
|
@@ -2806,83 +2900,67 @@ export class Multisig {
|
|
|
2806
2900
|
}
|
|
2807
2901
|
}
|
|
2808
2902
|
|
|
2809
|
-
private async checkProposalMetadataBinding(
|
|
2903
|
+
private async checkProposalMetadataBinding(
|
|
2904
|
+
proposal: Proposal,
|
|
2905
|
+
syncChain?: () => Promise<void>,
|
|
2906
|
+
): Promise<string> {
|
|
2810
2907
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
2811
|
-
|
|
2812
2908
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
2909
|
+
const boundBlockNum = this.requireProposalBoundBlockNum(proposal.id, proposal.metadata, summary);
|
|
2813
2910
|
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
try {
|
|
2821
|
-
const anchorCommitment = normalizeHexWord(anchor.commitment().toHex());
|
|
2822
|
-
const summaryBlockCommitment = normalizeHexWord(summary.blockCommitment().toHex());
|
|
2823
|
-
if (anchorCommitment !== summaryBlockCommitment) {
|
|
2824
|
-
throw new Error(
|
|
2825
|
-
`Invalid proposal: chain anchor does not match the block commitment bound into the tx_summary for ${proposal.id}`,
|
|
2826
|
-
);
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
if (proposal.metadata.proposalType === 'custom') {
|
|
2830
|
-
// Custom proposals have no per-type reconstruction recipe;
|
|
2831
|
-
// the id ↔ tx_summary commitment match above is the only available
|
|
2832
|
-
// integrity guarantee for an opaque proposal.
|
|
2833
|
-
return txSummaryCommitment;
|
|
2834
|
-
}
|
|
2911
|
+
if (proposal.metadata.proposalType === 'custom') {
|
|
2912
|
+
// Custom proposals have no per-type reconstruction recipe; the id and
|
|
2913
|
+
// bound-block checks above are the only available integrity guarantee
|
|
2914
|
+
// for an opaque proposal.
|
|
2915
|
+
return txSummaryCommitment;
|
|
2916
|
+
}
|
|
2835
2917
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2918
|
+
// The salt check needs no re-execution, so it runs for every built-in
|
|
2919
|
+
// type, switch_guardian included (as in the Rust SDK).
|
|
2920
|
+
const binding = proposalRequestBinding(
|
|
2921
|
+
summary,
|
|
2922
|
+
boundBlockNum,
|
|
2923
|
+
this.requireProposalSaltHex(proposal.id, proposal.metadata),
|
|
2924
|
+
);
|
|
2925
|
+
if (summarySaltHex(summary) !== binding.saltHex) {
|
|
2926
|
+
throw new Error(
|
|
2927
|
+
`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
|
|
2843
2928
|
);
|
|
2844
|
-
|
|
2845
|
-
throw new Error(
|
|
2846
|
-
`Invalid proposal: metadata salt does not match the salt bound into the tx_summary for ${proposal.id}`,
|
|
2847
|
-
);
|
|
2848
|
-
}
|
|
2929
|
+
}
|
|
2849
2930
|
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2931
|
+
if (proposal.metadata.proposalType === 'switch_guardian') {
|
|
2932
|
+
// Re-execution would mutate the WASM account twice. The proposal ID,
|
|
2933
|
+
// the salt above and the guardian endpoint commitment provide the
|
|
2934
|
+
// binding checks for this type.
|
|
2935
|
+
return txSummaryCommitment;
|
|
2936
|
+
}
|
|
2856
2937
|
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2864
|
-
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2865
|
-
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2866
|
-
// that never held these notes reproduces a different commitment.
|
|
2867
|
-
if (
|
|
2868
|
-
proposal.metadata.proposalType === 'consume_notes' &&
|
|
2869
|
-
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
|
|
2870
|
-
) {
|
|
2871
|
-
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2872
|
-
}
|
|
2938
|
+
// The rebuild reads the chain's fee faucet from the synced protocol
|
|
2939
|
+
// configuration and executes at the tip, so the store has to have synced
|
|
2940
|
+
// to the block the summary binds; a cosigner that has only just loaded
|
|
2941
|
+
// the account has not.
|
|
2942
|
+
await this.syncToBoundBlock(binding.boundBlockNum, syncChain);
|
|
2873
2943
|
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2944
|
+
// A consume-notes summary commits to *authenticated* consumption (see
|
|
2945
|
+
// ensureNotesAuthenticated), which miden-client decides from this store
|
|
2946
|
+
// alone. Put the store in that mode before the rebuild, or a cosigner
|
|
2947
|
+
// that never held these notes reproduces a different commitment.
|
|
2948
|
+
if (
|
|
2949
|
+
proposal.metadata.proposalType === 'consume_notes' &&
|
|
2950
|
+
proposal.metadata.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2
|
|
2951
|
+
) {
|
|
2952
|
+
await this.ensureNotesAuthenticated(decodeEmbeddedConsumeNotes(proposal.metadata));
|
|
2953
|
+
}
|
|
2877
2954
|
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2955
|
+
const request = await this.buildTransactionRequestFromMetadata(proposal.metadata, binding);
|
|
2956
|
+
const reconstructed = await executeForSummaryAtTip(this.midenClient, this._accountId, request);
|
|
2957
|
+
const reconstructedCommitment = normalizeHexWord(reconstructed.toCommitment().toHex());
|
|
2881
2958
|
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
anchor.free();
|
|
2959
|
+
if (reconstructedCommitment !== txSummaryCommitment) {
|
|
2960
|
+
throw new Error(`Invalid proposal: metadata does not match tx_summary for ${proposal.id}`);
|
|
2885
2961
|
}
|
|
2962
|
+
|
|
2963
|
+
return txSummaryCommitment;
|
|
2886
2964
|
}
|
|
2887
2965
|
|
|
2888
2966
|
/**
|
|
@@ -2934,19 +3012,52 @@ export class Multisig {
|
|
|
2934
3012
|
}
|
|
2935
3013
|
|
|
2936
3014
|
/**
|
|
2937
|
-
*
|
|
2938
|
-
*
|
|
2939
|
-
*
|
|
2940
|
-
*
|
|
3015
|
+
* The block a proposal's signed summary binds, read from the summary as the
|
|
3016
|
+
* Rust SDK does.
|
|
3017
|
+
*
|
|
3018
|
+
* GUARDIAN serves `boundBlockNum` unsigned and the response is cast, not
|
|
3019
|
+
* parsed. An absent value, from a proposal a 0.18.0 client made, is accepted;
|
|
3020
|
+
* a legacy `chainAnchor` is not read. A present value has to be a block
|
|
3021
|
+
* number equal to the summary's, for every proposal type, so a wrong one is
|
|
3022
|
+
* refused by name before anything executes or is signed.
|
|
3023
|
+
*
|
|
3024
|
+
* @throws BoundBlockMismatchError when the served value names another block.
|
|
2941
3025
|
*/
|
|
2942
|
-
private
|
|
2943
|
-
|
|
3026
|
+
private requireProposalBoundBlockNum(
|
|
3027
|
+
proposalId: string,
|
|
3028
|
+
metadata: ProposalMetadata,
|
|
3029
|
+
summary: TransactionSummary,
|
|
3030
|
+
): number {
|
|
3031
|
+
const declared = this.servedBoundBlockNum(proposalId, metadata);
|
|
3032
|
+
const boundBlockNum = summaryBoundBlockNum(summary);
|
|
3033
|
+
if (declared !== undefined && declared !== boundBlockNum) {
|
|
3034
|
+
throw new BoundBlockMismatchError({
|
|
3035
|
+
proposalId,
|
|
3036
|
+
declaredBoundBlockNum: declared,
|
|
3037
|
+
boundBlockNum,
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
return boundBlockNum;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/** The served `boundBlockNum`, `undefined` when absent; throws when malformed. */
|
|
3044
|
+
private servedBoundBlockNum(proposalId: string, metadata: ProposalMetadata): number | undefined {
|
|
3045
|
+
const boundBlockNum: unknown = metadata.boundBlockNum;
|
|
3046
|
+
if (boundBlockNum === undefined || boundBlockNum === null) {
|
|
3047
|
+
return undefined;
|
|
3048
|
+
}
|
|
3049
|
+
if (
|
|
3050
|
+
typeof boundBlockNum !== 'number' ||
|
|
3051
|
+
!Number.isInteger(boundBlockNum) ||
|
|
3052
|
+
boundBlockNum < 0 ||
|
|
3053
|
+
boundBlockNum > MAX_BLOCK_NUM
|
|
3054
|
+
) {
|
|
2944
3055
|
throw new Error(
|
|
2945
|
-
`Proposal ${proposalId} has
|
|
2946
|
-
|
|
3056
|
+
`Proposal ${proposalId} has a malformed boundBlockNum '${String(boundBlockNum)}': ` +
|
|
3057
|
+
`expected an integer between 0 and ${MAX_BLOCK_NUM}`,
|
|
2947
3058
|
);
|
|
2948
3059
|
}
|
|
2949
|
-
return
|
|
3060
|
+
return boundBlockNum;
|
|
2950
3061
|
}
|
|
2951
3062
|
|
|
2952
3063
|
/**
|