@openzeppelin/miden-multisig-client 0.15.2 → 0.16.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 +37 -1
- package/dist/account/builder.d.ts +2 -1
- package/dist/account/builder.d.ts.map +1 -1
- package/dist/account/builder.js +1 -0
- package/dist/account/builder.js.map +1 -1
- package/dist/account/builder.test.js +2 -2
- package/dist/account/builder.test.js.map +1 -1
- package/dist/client.d.ts +17 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +46 -15
- package/dist/client.test.js.map +1 -1
- package/dist/connectivity.d.ts +38 -0
- package/dist/connectivity.d.ts.map +1 -0
- package/dist/connectivity.js +97 -0
- package/dist/connectivity.js.map +1 -0
- package/dist/connectivity.test.d.ts +2 -0
- package/dist/connectivity.test.d.ts.map +1 -0
- package/dist/connectivity.test.js +61 -0
- package/dist/connectivity.test.js.map +1 -0
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/multisig.d.ts +130 -8
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +220 -24
- package/dist/multisig.js.map +1 -1
- package/dist/multisig.test.js +427 -89
- package/dist/multisig.test.js.map +1 -1
- package/dist/proposal/factory.d.ts.map +1 -1
- package/dist/proposal/factory.js +10 -0
- package/dist/proposal/factory.js.map +1 -1
- package/dist/proposal/factory.test.d.ts +2 -0
- package/dist/proposal/factory.test.d.ts.map +1 -0
- package/dist/proposal/factory.test.js +32 -0
- package/dist/proposal/factory.test.js.map +1 -0
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +12 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/proposal/metadata.test.js +49 -0
- package/dist/proposal/metadata.test.js.map +1 -1
- package/dist/prover/config.d.ts +16 -0
- package/dist/prover/config.d.ts.map +1 -0
- package/dist/prover/config.js +54 -0
- package/dist/prover/config.js.map +1 -0
- package/dist/prover/config.test.d.ts +2 -0
- package/dist/prover/config.test.d.ts.map +1 -0
- package/dist/prover/config.test.js +54 -0
- package/dist/prover/config.test.js.map +1 -0
- package/dist/prover/errors.d.ts +2 -0
- package/dist/prover/errors.d.ts.map +1 -0
- package/dist/prover/errors.js +158 -0
- package/dist/prover/errors.js.map +1 -0
- package/dist/prover/errors.test.d.ts +2 -0
- package/dist/prover/errors.test.d.ts.map +1 -0
- package/dist/prover/errors.test.js +24 -0
- package/dist/prover/errors.test.js.map +1 -0
- package/dist/prover/retry.d.ts +10 -0
- package/dist/prover/retry.d.ts.map +1 -0
- package/dist/prover/retry.js +32 -0
- package/dist/prover/retry.js.map +1 -0
- package/dist/prover/retry.test.d.ts +2 -0
- package/dist/prover/retry.test.d.ts.map +1 -0
- package/dist/prover/retry.test.js +20 -0
- package/dist/prover/retry.test.js.map +1 -0
- package/dist/prover/workflow.d.ts +11 -0
- package/dist/prover/workflow.d.ts.map +1 -0
- package/dist/prover/workflow.js +18 -0
- package/dist/prover/workflow.js.map +1 -0
- package/dist/prover/workflow.test.d.ts +2 -0
- package/dist/prover/workflow.test.d.ts.map +1 -0
- package/dist/prover/workflow.test.js +80 -0
- package/dist/prover/workflow.test.js.map +1 -0
- package/dist/raw-client.d.ts +2 -2
- package/dist/raw-client.d.ts.map +1 -1
- package/dist/raw-client.js +14 -4
- package/dist/raw-client.js.map +1 -1
- package/dist/raw-client.test.js +25 -3
- package/dist/raw-client.test.js.map +1 -1
- package/dist/transaction/consumeNotes.d.ts +6 -2
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +0 -4
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/transaction/options.d.ts +3 -0
- package/dist/transaction/options.d.ts.map +1 -1
- package/dist/transaction/p2id.d.ts +27 -2
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +56 -4
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction/p2id.test.js +66 -6
- package/dist/transaction/p2id.test.js.map +1 -1
- package/dist/transaction/summary.d.ts +2 -1
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction/updateGuardian.d.ts +6 -2
- package/dist/transaction/updateGuardian.d.ts.map +1 -1
- package/dist/transaction/updateGuardian.js.map +1 -1
- package/dist/transaction/updateProcedureThreshold.d.ts +7 -2
- package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
- package/dist/transaction/updateProcedureThreshold.js.map +1 -1
- package/dist/transaction/updateSigners.d.ts +7 -2
- package/dist/transaction/updateSigners.d.ts.map +1 -1
- package/dist/transaction/updateSigners.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 +5 -0
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +3 -0
- package/dist/types/proposal.js.map +1 -1
- package/package.json +3 -3
- package/src/account/builder.test.ts +19 -11
- package/src/account/builder.ts +2 -1
- package/src/client.test.ts +67 -15
- package/src/client.ts +35 -12
- package/src/connectivity.test.ts +67 -0
- package/src/connectivity.ts +111 -0
- package/src/index.ts +25 -1
- package/src/multisig.test.ts +526 -94
- package/src/multisig.ts +270 -25
- package/src/proposal/factory.test.ts +40 -0
- package/src/proposal/factory.ts +11 -1
- package/src/proposal/metadata.test.ts +60 -0
- package/src/proposal/metadata.ts +14 -0
- package/src/prover/config.test.ts +90 -0
- package/src/prover/config.ts +78 -0
- package/src/prover/errors.test.ts +53 -0
- package/src/prover/errors.ts +178 -0
- package/src/prover/retry.test.ts +38 -0
- package/src/prover/retry.ts +46 -0
- package/src/prover/test-node.d.ts +6 -0
- package/src/prover/workflow.test.ts +109 -0
- package/src/prover/workflow.ts +19 -0
- package/src/raw-client.test.ts +41 -3
- package/src/raw-client.ts +15 -5
- package/src/transaction/consumeNotes.ts +11 -1
- package/src/transaction/options.ts +4 -0
- package/src/transaction/p2id.test.ts +98 -8
- package/src/transaction/p2id.ts +91 -12
- package/src/transaction/summary.ts +12 -0
- package/src/transaction/updateGuardian.ts +11 -1
- package/src/transaction/updateProcedureThreshold.ts +13 -1
- package/src/transaction/updateSigners.ts +13 -1
- package/src/transaction.ts +4 -0
- package/src/types/proposal.ts +9 -0
package/src/multisig.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* for proposal management.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { GuardianHttpClient, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
|
|
8
|
+
import { GuardianHttpClient, type AbandonCandidateResponse, type AbandonStatus, type DeltaObject, type ProposalSignature, type Signer, type AuthConfig, type StateObject } from '@openzeppelin/guardian-client';
|
|
9
9
|
import type {
|
|
10
10
|
ConsumableNote,
|
|
11
11
|
ExportedProposal,
|
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
import type { ProcedureName } from './procedures.js';
|
|
20
20
|
import type {
|
|
21
21
|
MidenClient,
|
|
22
|
-
TransactionProver,
|
|
23
22
|
WasmWebClient,
|
|
24
23
|
} from '@miden-sdk/miden-sdk';
|
|
25
24
|
import {
|
|
@@ -29,6 +28,9 @@ import {
|
|
|
29
28
|
Endpoint,
|
|
30
29
|
FeltArray,
|
|
31
30
|
Note,
|
|
31
|
+
NoteExportFormat,
|
|
32
|
+
NoteFile,
|
|
33
|
+
NoteType,
|
|
32
34
|
RpcClient,
|
|
33
35
|
Signature,
|
|
34
36
|
TransactionRequest,
|
|
@@ -41,7 +43,10 @@ import {
|
|
|
41
43
|
buildUpdateProcedureThresholdTransactionRequest,
|
|
42
44
|
buildUpdateGuardianTransactionRequest,
|
|
43
45
|
buildConsumeNotesTransactionRequest,
|
|
46
|
+
buildP2idNoteFromMetadata,
|
|
44
47
|
buildP2idTransactionRequest,
|
|
48
|
+
parseP2idNoteType,
|
|
49
|
+
p2idNoteTypeToMetadata,
|
|
45
50
|
} from './transaction.js';
|
|
46
51
|
import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
|
|
47
52
|
import {
|
|
@@ -73,7 +78,16 @@ import { AccountInspector } from './inspector.js';
|
|
|
73
78
|
import { ProposalFactory } from './proposal/factory.js';
|
|
74
79
|
import { ProposalMetadataCodec } from './proposal/metadata.js';
|
|
75
80
|
import { ProposalSignatures } from './proposal/signatures.js';
|
|
76
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
getRawMidenClient,
|
|
83
|
+
getTransactionProver,
|
|
84
|
+
requireMidenRpcEndpoint,
|
|
85
|
+
} from './raw-client.js';
|
|
86
|
+
import {
|
|
87
|
+
resolveProverConfig,
|
|
88
|
+
type ResolvedProverConfig,
|
|
89
|
+
} from './prover/config.js';
|
|
90
|
+
import { ProverWorkflow } from './prover/workflow.js';
|
|
77
91
|
|
|
78
92
|
/**
|
|
79
93
|
* Result of fetching account state from GUARDIAN.
|
|
@@ -136,9 +150,9 @@ export class Multisig {
|
|
|
136
150
|
private readonly signer: Signer;
|
|
137
151
|
private readonly midenClient: MidenClient;
|
|
138
152
|
private readonly rawClientPromise: Promise<WasmWebClient>;
|
|
139
|
-
private readonly
|
|
153
|
+
private readonly proverWorkflow: ProverWorkflow;
|
|
140
154
|
private readonly _accountId: string;
|
|
141
|
-
private readonly midenRpcEndpoint
|
|
155
|
+
private readonly midenRpcEndpoint: string;
|
|
142
156
|
private proposals: Map<string, Proposal> = new Map();
|
|
143
157
|
|
|
144
158
|
constructor(
|
|
@@ -147,8 +161,9 @@ export class Multisig {
|
|
|
147
161
|
guardian: GuardianHttpClient,
|
|
148
162
|
signer: Signer,
|
|
149
163
|
midenClient: MidenClient,
|
|
150
|
-
accountId
|
|
151
|
-
midenRpcEndpoint
|
|
164
|
+
accountId: string | undefined,
|
|
165
|
+
midenRpcEndpoint: string,
|
|
166
|
+
proverConfig?: ResolvedProverConfig,
|
|
152
167
|
) {
|
|
153
168
|
this.account = account;
|
|
154
169
|
this.threshold = config.threshold;
|
|
@@ -162,15 +177,15 @@ export class Multisig {
|
|
|
162
177
|
this.signer = signer;
|
|
163
178
|
this.midenClient = midenClient;
|
|
164
179
|
this._accountId = accountId ?? (account ? accountIdToHex(account) : '');
|
|
165
|
-
this.midenRpcEndpoint = midenRpcEndpoint;
|
|
166
|
-
this.rawClientPromise = getRawMidenClient(midenClient, midenRpcEndpoint);
|
|
167
|
-
this.
|
|
180
|
+
this.midenRpcEndpoint = requireMidenRpcEndpoint(midenRpcEndpoint);
|
|
181
|
+
this.rawClientPromise = getRawMidenClient(midenClient, this.midenRpcEndpoint);
|
|
182
|
+
this.proverWorkflow = new ProverWorkflow(
|
|
183
|
+
this.midenClient,
|
|
184
|
+
proverConfig ?? resolveProverConfig(undefined, getTransactionProver(midenClient)),
|
|
185
|
+
);
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
private getMidenRpcEndpoint(): string {
|
|
171
|
-
if (!this.midenRpcEndpoint) {
|
|
172
|
-
throw new Error('Missing Miden RPC endpoint in MultisigClient configuration');
|
|
173
|
-
}
|
|
174
189
|
return this.midenRpcEndpoint;
|
|
175
190
|
}
|
|
176
191
|
|
|
@@ -213,6 +228,19 @@ export class Multisig {
|
|
|
213
228
|
return this.signer.commitment;
|
|
214
229
|
}
|
|
215
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the account from the web client's store, falling back to the
|
|
233
|
+
* `account` snapshot when the store has no record.
|
|
234
|
+
*
|
|
235
|
+
* Transaction execution reads the store, and other flows (e.g. consume-notes
|
|
236
|
+
* finalize) update it without refreshing the snapshot, so vault lookups must
|
|
237
|
+
* source from the store to see the same state execution will.
|
|
238
|
+
*/
|
|
239
|
+
async getStoreAccount(): Promise<Account> {
|
|
240
|
+
const webClient = await this.getRawClient();
|
|
241
|
+
return (await webClient.getAccount(AccountId.fromHex(this._accountId))) ?? this.account;
|
|
242
|
+
}
|
|
243
|
+
|
|
216
244
|
/**
|
|
217
245
|
* Maps a proposal type to the procedure that determines its threshold.
|
|
218
246
|
*/
|
|
@@ -286,7 +314,13 @@ export class Multisig {
|
|
|
286
314
|
* Sync account state from GUARDIAN into the local Miden client store.
|
|
287
315
|
*
|
|
288
316
|
* If the GUARDIAN commitment differs from the local commitment (or the account
|
|
289
|
-
* is missing locally)
|
|
317
|
+
* is missing locally) and the GUARDIAN state is safe to import, the local store
|
|
318
|
+
* is overwritten with the GUARDIAN state. When the GUARDIAN is merely *behind*
|
|
319
|
+
* local — e.g. the pushed execution delta has not been canonicalized yet
|
|
320
|
+
* (see OpenZeppelin/guardian#316) — the local state is already ahead and
|
|
321
|
+
* on-chain-verifiable, so it is kept as authoritative. Either way, config is
|
|
322
|
+
* refreshed from the resulting account so callers reading `Multisig.account`
|
|
323
|
+
* (e.g. the UI) observe the current state instead of a stale snapshot.
|
|
290
324
|
*/
|
|
291
325
|
async syncState(): Promise<AccountState> {
|
|
292
326
|
const state = await this.fetchState();
|
|
@@ -303,9 +337,10 @@ export class Multisig {
|
|
|
303
337
|
if (!localAccount || localCommitment !== guardianCommitment) {
|
|
304
338
|
const accountBytes = base64ToUint8Array(state.stateDataBase64);
|
|
305
339
|
const incomingAccount = Account.deserialize(accountBytes);
|
|
306
|
-
await this.
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
if (await this.isSafeToOverwriteLocalState(incomingAccount, localAccount)) {
|
|
341
|
+
await webClient.newAccount(incomingAccount, true);
|
|
342
|
+
accountForConfigRefresh = incomingAccount;
|
|
343
|
+
}
|
|
309
344
|
}
|
|
310
345
|
|
|
311
346
|
this.refreshConfigFromAccount(accountForConfigRefresh);
|
|
@@ -344,17 +379,37 @@ export class Multisig {
|
|
|
344
379
|
};
|
|
345
380
|
}
|
|
346
381
|
|
|
347
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Decide whether GUARDIAN-provided state may overwrite the local store.
|
|
384
|
+
*
|
|
385
|
+
* Returns `false` — rather than throwing — when the GUARDIAN state is simply
|
|
386
|
+
* *behind* local (lower nonce). That happens whenever the execution delta the
|
|
387
|
+
* client pushed has not been canonicalized by the GUARDIAN's background worker
|
|
388
|
+
* yet (see OpenZeppelin/guardian#316), or permanently if that candidate was
|
|
389
|
+
* discarded (#312 / #319). In that case the local account is already ahead and
|
|
390
|
+
* is independently verifiable against chain (`verifyStateCommitment`), so it is
|
|
391
|
+
* authoritative and must be kept, not clobbered; the caller keeps local and
|
|
392
|
+
* refreshes config from it.
|
|
393
|
+
*
|
|
394
|
+
* Still throws for genuine divergence: an incoming state at the *same* nonce as
|
|
395
|
+
* local but a different commitment, or an incoming state whose commitment does
|
|
396
|
+
* not match the on-chain commitment.
|
|
397
|
+
*/
|
|
398
|
+
private async isSafeToOverwriteLocalState(
|
|
348
399
|
incomingAccount: Account,
|
|
349
400
|
localAccount?: Account,
|
|
350
|
-
): Promise<
|
|
401
|
+
): Promise<boolean> {
|
|
351
402
|
if (localAccount) {
|
|
352
403
|
const localNonce = localAccount.nonce().asInt();
|
|
353
404
|
const incomingNonce = incomingAccount.nonce().asInt();
|
|
354
405
|
|
|
355
|
-
if (incomingNonce
|
|
406
|
+
if (incomingNonce < localNonce) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (incomingNonce === localNonce) {
|
|
356
411
|
throw new Error(
|
|
357
|
-
`Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()}
|
|
412
|
+
`Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${this._accountId}`
|
|
358
413
|
);
|
|
359
414
|
}
|
|
360
415
|
}
|
|
@@ -362,7 +417,7 @@ export class Multisig {
|
|
|
362
417
|
const accountId = AccountId.fromHex(this._accountId);
|
|
363
418
|
const onChainCommitment = await this.getOnChainCommitment(accountId);
|
|
364
419
|
if (!onChainCommitment) {
|
|
365
|
-
return;
|
|
420
|
+
return true;
|
|
366
421
|
}
|
|
367
422
|
|
|
368
423
|
const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
|
|
@@ -371,6 +426,8 @@ export class Multisig {
|
|
|
371
426
|
`Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${this._accountId}`
|
|
372
427
|
);
|
|
373
428
|
}
|
|
429
|
+
|
|
430
|
+
return true;
|
|
374
431
|
}
|
|
375
432
|
|
|
376
433
|
private async getOnChainCommitment(accountId: AccountId): Promise<string | null> {
|
|
@@ -807,23 +864,30 @@ export class Multisig {
|
|
|
807
864
|
* @param faucetId - Faucet/token account ID (hex string)
|
|
808
865
|
* @param amount - Amount to send
|
|
809
866
|
* @param nonce - Optional proposal nonce (defaults to Date.now())
|
|
867
|
+
* @param options - Optional settings; `noteType` selects the created note's
|
|
868
|
+
* visibility (defaults to `NoteType.Public`, issue #322)
|
|
810
869
|
*/
|
|
811
870
|
async createP2idProposal(
|
|
812
871
|
recipientId: string,
|
|
813
872
|
faucetId: string,
|
|
814
873
|
amount: bigint,
|
|
815
874
|
nonce?: number,
|
|
875
|
+
options: { noteType?: NoteType } = {},
|
|
816
876
|
): Promise<Proposal> {
|
|
817
877
|
const webClient = await this.getRawClient();
|
|
818
878
|
if (amount <= 0n) {
|
|
819
879
|
throw new Error('Amount must be greater than 0');
|
|
820
880
|
}
|
|
821
881
|
|
|
882
|
+
const account = await this.getStoreAccount();
|
|
883
|
+
|
|
822
884
|
const { request, salt } = buildP2idTransactionRequest(
|
|
823
885
|
this._accountId,
|
|
824
886
|
recipientId,
|
|
825
887
|
faucetId,
|
|
826
888
|
amount,
|
|
889
|
+
account,
|
|
890
|
+
{ noteType: options.noteType },
|
|
827
891
|
);
|
|
828
892
|
|
|
829
893
|
const summary = await executeForSummary(webClient, this._accountId, request);
|
|
@@ -837,6 +901,8 @@ export class Multisig {
|
|
|
837
901
|
recipientId,
|
|
838
902
|
faucetId,
|
|
839
903
|
amount: amount.toString(),
|
|
904
|
+
// Omitted for public notes so the wire shape matches pre-#322 proposals.
|
|
905
|
+
noteType: p2idNoteTypeToMetadata(options.noteType),
|
|
840
906
|
description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`,
|
|
841
907
|
};
|
|
842
908
|
|
|
@@ -894,6 +960,151 @@ export class Multisig {
|
|
|
894
960
|
return notes;
|
|
895
961
|
}
|
|
896
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Export a note created by this multisig account as serialized note-file
|
|
965
|
+
* bytes for out-of-band delivery (issue #356).
|
|
966
|
+
*
|
|
967
|
+
* A private note publishes only its commitment on chain, so the recipient
|
|
968
|
+
* can never learn its contents via sync; the sender must hand them the
|
|
969
|
+
* bytes produced here, which they load with {@link importNoteFromBytes}.
|
|
970
|
+
*
|
|
971
|
+
* The note must be an output note of this client (created by a transaction
|
|
972
|
+
* this client executed). When the note's on-chain inclusion proof is
|
|
973
|
+
* already known (after a post-commit sync) the full note with proof is
|
|
974
|
+
* exported; otherwise the note details are exported and the importer's
|
|
975
|
+
* client tracks the note until it commits on chain.
|
|
976
|
+
*
|
|
977
|
+
* @param noteId - ID of the note to export (hex string)
|
|
978
|
+
* @returns Serialized note file bytes
|
|
979
|
+
*/
|
|
980
|
+
async exportNoteToBytes(noteId: string): Promise<Uint8Array> {
|
|
981
|
+
const webClient = await this.getRawClient();
|
|
982
|
+
const trimmedNoteId = noteId.trim();
|
|
983
|
+
|
|
984
|
+
let record;
|
|
985
|
+
try {
|
|
986
|
+
record = await webClient.getOutputNote(trimmedNoteId);
|
|
987
|
+
} catch (err) {
|
|
988
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
989
|
+
throw new Error(
|
|
990
|
+
`Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported: ${detail}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
if (!record) {
|
|
994
|
+
throw new Error(
|
|
995
|
+
`Output note ${trimmedNoteId} not found in the local store; only notes created by this client can be exported`,
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const format = record.inclusionProof()
|
|
1000
|
+
? NoteExportFormat.Full
|
|
1001
|
+
: NoteExportFormat.Details;
|
|
1002
|
+
const noteFile = await webClient.exportNoteFile(trimmedNoteId, format);
|
|
1003
|
+
return noteFile.serialize();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Export a note created by this multisig account as a note file downloaded
|
|
1008
|
+
* by the browser (issue #356). Browser-only convenience over
|
|
1009
|
+
* {@link exportNoteToBytes}; use that method directly in non-DOM
|
|
1010
|
+
* environments.
|
|
1011
|
+
*
|
|
1012
|
+
* @param noteId - ID of the note to export (hex string)
|
|
1013
|
+
* @param filename - Download filename; defaults to `note_<id>.mno`
|
|
1014
|
+
*/
|
|
1015
|
+
async exportNoteToFile(noteId: string, filename?: string): Promise<void> {
|
|
1016
|
+
if (typeof document === 'undefined') {
|
|
1017
|
+
throw new Error('exportNoteToFile requires a browser environment; use exportNoteToBytes instead');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const trimmedNoteId = noteId.trim();
|
|
1021
|
+
const noteBytes = await this.exportNoteToBytes(trimmedNoteId);
|
|
1022
|
+
|
|
1023
|
+
const blob = new Blob([noteBytes as BlobPart], { type: 'application/octet-stream' });
|
|
1024
|
+
const url = URL.createObjectURL(blob);
|
|
1025
|
+
try {
|
|
1026
|
+
const anchor = document.createElement('a');
|
|
1027
|
+
anchor.href = url;
|
|
1028
|
+
anchor.download = filename ?? `note_${trimmedNoteId}.mno`;
|
|
1029
|
+
anchor.click();
|
|
1030
|
+
} finally {
|
|
1031
|
+
URL.revokeObjectURL(url);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Import a note file received out-of-band (issue #356) so the note can be
|
|
1037
|
+
* consumed by this multisig account.
|
|
1038
|
+
*
|
|
1039
|
+
* Sync the Miden client with the network afterwards so the note's on-chain
|
|
1040
|
+
* commitment is tracked and the note shows up in {@link getConsumableNotes};
|
|
1041
|
+
* it can then be consumed via {@link createConsumeNotesProposal}.
|
|
1042
|
+
*
|
|
1043
|
+
* @param noteBytes - Serialized note file bytes produced by
|
|
1044
|
+
* {@link exportNoteToBytes}
|
|
1045
|
+
* @returns The note ID when the file carries one, or the note's details
|
|
1046
|
+
* commitment for a details-only file
|
|
1047
|
+
*/
|
|
1048
|
+
async importNoteFromBytes(noteBytes: Uint8Array): Promise<string> {
|
|
1049
|
+
const webClient = await this.getRawClient();
|
|
1050
|
+
|
|
1051
|
+
let noteFile: NoteFile;
|
|
1052
|
+
try {
|
|
1053
|
+
noteFile = NoteFile.deserialize(noteBytes);
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1056
|
+
throw new Error(`failed to decode note file: ${detail}`);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return webClient.importNoteFile(noteFile);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Import a note file received out-of-band (issue #356) from a browser
|
|
1064
|
+
* `File`/`Blob` (e.g. a file-input selection). See
|
|
1065
|
+
* {@link importNoteFromBytes} for the returned identifier semantics.
|
|
1066
|
+
*/
|
|
1067
|
+
async importNoteFromFile(file: Blob): Promise<string> {
|
|
1068
|
+
const noteBytes = new Uint8Array(await file.arrayBuffer());
|
|
1069
|
+
return this.importNoteFromBytes(noteBytes);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Compute the ID of the note a P2ID proposal will create when executed.
|
|
1074
|
+
*
|
|
1075
|
+
* The P2ID note is rebuilt deterministically from the proposal salt, so the
|
|
1076
|
+
* ID is known ahead of execution. For a private P2ID this is the ID to pass
|
|
1077
|
+
* to {@link exportNoteToBytes} after executing, so the note file can be delivered
|
|
1078
|
+
* to the recipient out-of-band (issue #356).
|
|
1079
|
+
*
|
|
1080
|
+
* Call this before executing the proposal: the asset is derived from the
|
|
1081
|
+
* current vault state, which execution itself changes.
|
|
1082
|
+
*/
|
|
1083
|
+
async getP2idNoteId(proposal: Proposal): Promise<string> {
|
|
1084
|
+
const metadata = proposal.metadata;
|
|
1085
|
+
if (
|
|
1086
|
+
metadata.proposalType !== 'p2id' ||
|
|
1087
|
+
!metadata.recipientId ||
|
|
1088
|
+
!metadata.faucetId ||
|
|
1089
|
+
!metadata.amount ||
|
|
1090
|
+
!metadata.saltHex
|
|
1091
|
+
) {
|
|
1092
|
+
throw new Error('getP2idNoteId requires a P2ID proposal with recipient, faucet, amount, and salt metadata');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const account = await this.getStoreAccount();
|
|
1096
|
+
const note = buildP2idNoteFromMetadata(
|
|
1097
|
+
this._accountId,
|
|
1098
|
+
metadata.recipientId,
|
|
1099
|
+
metadata.faucetId,
|
|
1100
|
+
BigInt(metadata.amount),
|
|
1101
|
+
account,
|
|
1102
|
+
parseP2idNoteType(metadata.noteType),
|
|
1103
|
+
metadata.saltHex,
|
|
1104
|
+
);
|
|
1105
|
+
return note.id().toString();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
897
1108
|
/**
|
|
898
1109
|
* Sign a proposal.
|
|
899
1110
|
*
|
|
@@ -902,6 +1113,38 @@ export class Multisig {
|
|
|
902
1113
|
*
|
|
903
1114
|
* @param proposalId - The proposal commitment/ID (this is also what gets signed)
|
|
904
1115
|
*/
|
|
1116
|
+
/**
|
|
1117
|
+
* Request abandonment of a pending canonicalization candidate whose
|
|
1118
|
+
* transaction will never land on-chain (issue #319) — e.g. after an
|
|
1119
|
+
* approved transaction died client-side (RPC submit failure, prover
|
|
1120
|
+
* timeout, crash).
|
|
1121
|
+
*
|
|
1122
|
+
* Records an abandon *intent* on GUARDIAN: the account stays locked
|
|
1123
|
+
* until the guardian's canonicalization worker confirms over a short
|
|
1124
|
+
* quarantine (typically well under a minute) that the transaction did
|
|
1125
|
+
* not land, then releases the account. Poll {@link abandonStatus} for
|
|
1126
|
+
* the resolution.
|
|
1127
|
+
*
|
|
1128
|
+
* `nonce` pins the exact candidate to release; it is the nonce the
|
|
1129
|
+
* proposal was pushed with. Retries are idempotent and preserve the
|
|
1130
|
+
* original request timestamp. Refused with `GUARDIAN_CANDIDATE_LANDED`
|
|
1131
|
+
* (409) when the transaction actually landed.
|
|
1132
|
+
*/
|
|
1133
|
+
async abandonCandidate(nonce: number): Promise<AbandonCandidateResponse> {
|
|
1134
|
+
return this.guardian.abandonCandidate(this._accountId, nonce);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Poll the resolution of an abandon request made with
|
|
1139
|
+
* {@link abandonCandidate}: `'waiting'` while the quarantine runs,
|
|
1140
|
+
* `'landed'` if the transaction landed after all, `'abandoned'` once
|
|
1141
|
+
* the account is released, `'unexpected'` for any state no abandon
|
|
1142
|
+
* flow produces.
|
|
1143
|
+
*/
|
|
1144
|
+
async abandonStatus(nonce: number): Promise<AbandonStatus> {
|
|
1145
|
+
return this.guardian.abandonStatus(this._accountId, nonce);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
905
1148
|
async signProposal(proposalId: string): Promise<Proposal> {
|
|
906
1149
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
907
1150
|
const existingProposal = await this.getProposalForSigning(proposalId, normalizedProposalId);
|
|
@@ -964,7 +1207,7 @@ export class Multisig {
|
|
|
964
1207
|
const { metadata, finalRequest, proposal } = await this.prepareProposalExecution(proposalId);
|
|
965
1208
|
|
|
966
1209
|
const accountId = AccountId.fromHex(this._accountId);
|
|
967
|
-
await this.
|
|
1210
|
+
await this.proverWorkflow.submit(accountId, finalRequest);
|
|
968
1211
|
|
|
969
1212
|
if (metadata.proposalType === 'switch_guardian') {
|
|
970
1213
|
if (!metadata.newGuardianEndpoint || !metadata.newGuardianPubkey) {
|
|
@@ -1023,7 +1266,7 @@ export class Multisig {
|
|
|
1023
1266
|
* after `prepareCustomExecution` rebuilds its request with the returned advice.
|
|
1024
1267
|
*/
|
|
1025
1268
|
async submitTransaction(request: TransactionRequest): Promise<void> {
|
|
1026
|
-
await this.
|
|
1269
|
+
await this.proverWorkflow.submit(AccountId.fromHex(this._accountId), request);
|
|
1027
1270
|
}
|
|
1028
1271
|
|
|
1029
1272
|
/**
|
|
@@ -1678,12 +1921,14 @@ export class Multisig {
|
|
|
1678
1921
|
throw new UnsupportedMetadataVersionError(version);
|
|
1679
1922
|
}
|
|
1680
1923
|
case 'p2id': {
|
|
1924
|
+
const account = await this.getStoreAccount();
|
|
1681
1925
|
const { request } = buildP2idTransactionRequest(
|
|
1682
1926
|
this._accountId,
|
|
1683
1927
|
metadata.recipientId,
|
|
1684
1928
|
metadata.faucetId,
|
|
1685
1929
|
BigInt(metadata.amount),
|
|
1686
|
-
|
|
1930
|
+
account,
|
|
1931
|
+
{ salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) }
|
|
1687
1932
|
);
|
|
1688
1933
|
return request;
|
|
1689
1934
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { DeltaStatus } from '@openzeppelin/guardian-client';
|
|
3
|
+
import { ProposalFactory } from './factory.js';
|
|
4
|
+
|
|
5
|
+
describe('ProposalFactory.toStatus', () => {
|
|
6
|
+
const factory = new ProposalFactory({
|
|
7
|
+
accountId: '0x' + 'a'.repeat(30),
|
|
8
|
+
signerCommitments: [],
|
|
9
|
+
resolveRequiredSignatures: () => 2,
|
|
10
|
+
});
|
|
11
|
+
// TS `private` is compile-time only; the mapping is worth pinning
|
|
12
|
+
// without dragging a full tx summary through `fromDelta`.
|
|
13
|
+
const toStatus = (status: DeltaStatus) =>
|
|
14
|
+
(factory as unknown as { toStatus: (s: DeltaStatus, t: string, sigs: unknown[]) => string })
|
|
15
|
+
.toStatus(status, 'p2id', []);
|
|
16
|
+
|
|
17
|
+
it('maps every terminal delta status to finalized (issue #345)', () => {
|
|
18
|
+
// A retained delta left the active candidate path: no longer
|
|
19
|
+
// signable, so finalized here — whether it landed is resolved by
|
|
20
|
+
// background reconciliation via the delta status, not the proposal
|
|
21
|
+
// list.
|
|
22
|
+
expect(toStatus({ status: 'canonical', timestamp: 't' })).toBe('finalized');
|
|
23
|
+
// Cast until the published guardian-client types include `retained`.
|
|
24
|
+
expect(
|
|
25
|
+
toStatus({
|
|
26
|
+
status: 'retained',
|
|
27
|
+
timestamp: 't',
|
|
28
|
+
reason: 'retry_exhausted',
|
|
29
|
+
} as unknown as DeltaStatus),
|
|
30
|
+
).toBe('finalized');
|
|
31
|
+
expect(toStatus({ status: 'discarded', timestamp: 't' })).toBe('finalized');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('maps active statuses to pending/ready', () => {
|
|
35
|
+
expect(toStatus({ status: 'candidate', timestamp: 't' })).toBe('ready');
|
|
36
|
+
expect(
|
|
37
|
+
toStatus({ status: 'pending', timestamp: 't', proposerId: 'p', cosignerSigs: [] }),
|
|
38
|
+
).toBe('pending');
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/proposal/factory.ts
CHANGED
|
@@ -156,7 +156,11 @@ export class ProposalFactory {
|
|
|
156
156
|
proposalType: ProposalType,
|
|
157
157
|
signatures: ProposalSignatureEntry[],
|
|
158
158
|
): ProposalStatus {
|
|
159
|
-
|
|
159
|
+
// The explicit widening keeps this compiling against published
|
|
160
|
+
// @openzeppelin/guardian-client types that predate the `retained`
|
|
161
|
+
// status (issue #345); it becomes redundant — but stays correct —
|
|
162
|
+
// once the lockfile picks up a release that includes it.
|
|
163
|
+
switch (status.status as DeltaStatus['status'] | 'retained') {
|
|
160
164
|
case 'pending': {
|
|
161
165
|
const signaturesRequired = this.options.resolveRequiredSignatures(proposalType);
|
|
162
166
|
return signatures.length >= signaturesRequired ? 'ready' : 'pending';
|
|
@@ -164,6 +168,12 @@ export class ProposalFactory {
|
|
|
164
168
|
case 'candidate':
|
|
165
169
|
return 'ready';
|
|
166
170
|
case 'canonical':
|
|
171
|
+
// A retained delta left the active candidate path (issue #345):
|
|
172
|
+
// no longer signable or submittable, so it is finalized from the
|
|
173
|
+
// proposal list's point of view. Whether its transaction landed is
|
|
174
|
+
// resolved by background reconciliation, surfaced via the delta
|
|
175
|
+
// status, not here.
|
|
176
|
+
case 'retained':
|
|
167
177
|
case 'discarded':
|
|
168
178
|
return 'finalized';
|
|
169
179
|
}
|
|
@@ -4,6 +4,7 @@ import { ProposalMetadataCodec } from './metadata.js';
|
|
|
4
4
|
import type {
|
|
5
5
|
ConsumeNotesProposalMetadata,
|
|
6
6
|
CustomProposalMetadata,
|
|
7
|
+
P2IdProposalMetadata,
|
|
7
8
|
} from '../types/proposal.js';
|
|
8
9
|
|
|
9
10
|
describe('ProposalMetadataCodec consume_notes v2 round-trip (issue #229)', () => {
|
|
@@ -97,3 +98,62 @@ describe('ProposalMetadataCodec custom proposal types (issue #266)', () => {
|
|
|
97
98
|
expect(back.targetThreshold).toBe(2);
|
|
98
99
|
});
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
describe('ProposalMetadataCodec p2id noteType (issue #322)', () => {
|
|
103
|
+
const baseWire: GuardianProposalMetadata = {
|
|
104
|
+
proposalType: 'p2id',
|
|
105
|
+
recipientId: '0xrecipient',
|
|
106
|
+
faucetId: '0xfaucet',
|
|
107
|
+
amount: '1000',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it('round-trips a private noteType through the codec', () => {
|
|
111
|
+
const md = ProposalMetadataCodec.fromGuardian({
|
|
112
|
+
...baseWire,
|
|
113
|
+
noteType: 'private',
|
|
114
|
+
}) as P2IdProposalMetadata;
|
|
115
|
+
expect(md.noteType).toBe('private');
|
|
116
|
+
|
|
117
|
+
const wire = ProposalMetadataCodec.toGuardian(md);
|
|
118
|
+
expect(wire.noteType).toBe('private');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('leaves noteType absent for legacy proposals (=> public)', () => {
|
|
122
|
+
const md = ProposalMetadataCodec.fromGuardian(baseWire) as P2IdProposalMetadata;
|
|
123
|
+
expect(md.noteType).toBeUndefined();
|
|
124
|
+
expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('canonicalizes an explicit public noteType to absent on encode', () => {
|
|
128
|
+
const md = {
|
|
129
|
+
proposalType: 'p2id',
|
|
130
|
+
description: '',
|
|
131
|
+
recipientId: '0xrecipient',
|
|
132
|
+
faucetId: '0xfaucet',
|
|
133
|
+
amount: '1000',
|
|
134
|
+
noteType: 'public',
|
|
135
|
+
} as P2IdProposalMetadata;
|
|
136
|
+
|
|
137
|
+
// toGuardian omits the field so a public note keeps the pre-#322 wire
|
|
138
|
+
// shape and matches the Rust encoder, even if handed an explicit 'public'.
|
|
139
|
+
expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('fromGuardian rejects an unsupported noteType', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
ProposalMetadataCodec.fromGuardian({ ...baseWire, noteType: 'encrypted' }),
|
|
145
|
+
).toThrow(/unsupported noteType/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('validate rejects an unsupported noteType', () => {
|
|
149
|
+
const md = {
|
|
150
|
+
proposalType: 'p2id',
|
|
151
|
+
description: '',
|
|
152
|
+
recipientId: '0xrecipient',
|
|
153
|
+
faucetId: '0xfaucet',
|
|
154
|
+
amount: '1000',
|
|
155
|
+
noteType: 'encrypted',
|
|
156
|
+
} as unknown as P2IdProposalMetadata;
|
|
157
|
+
expect(() => ProposalMetadataCodec.validate(md)).toThrow(/unsupported noteType/);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/proposal/metadata.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ProposalMetadata as GuardianProposalMetadata } from '@openzeppelin/guardian-client';
|
|
2
2
|
import type { ProposalMetadata } from '../types.js';
|
|
3
3
|
import { isProcedureName } from '../procedures.js';
|
|
4
|
+
import { isP2idNoteVisibility } from '../types/proposal.js';
|
|
4
5
|
|
|
5
6
|
export class ProposalMetadataCodec {
|
|
6
7
|
static toGuardian(metadata: ProposalMetadata): GuardianProposalMetadata {
|
|
@@ -25,6 +26,10 @@ export class ProposalMetadataCodec {
|
|
|
25
26
|
recipientId: metadata.recipientId,
|
|
26
27
|
faucetId: metadata.faucetId,
|
|
27
28
|
amount: metadata.amount,
|
|
29
|
+
// Canonicalize: emit note_type only when private, so a public note
|
|
30
|
+
// keeps the pre-#322 wire shape and matches the Rust encoder (which
|
|
31
|
+
// round-trips through the NoteType enum). Absent => public.
|
|
32
|
+
noteType: metadata.noteType === 'private' ? 'private' : undefined,
|
|
28
33
|
};
|
|
29
34
|
case 'switch_guardian':
|
|
30
35
|
return {
|
|
@@ -70,12 +75,18 @@ export class ProposalMetadataCodec {
|
|
|
70
75
|
if (!guardian.recipientId || !guardian.faucetId || !guardian.amount) {
|
|
71
76
|
throw new Error('p2id proposal is missing required metadata fields');
|
|
72
77
|
}
|
|
78
|
+
if (guardian.noteType !== undefined && !isP2idNoteVisibility(guardian.noteType)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`p2id proposal has unsupported noteType '${guardian.noteType}': expected 'public' or 'private'`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
73
83
|
return {
|
|
74
84
|
...base,
|
|
75
85
|
proposalType: 'p2id',
|
|
76
86
|
recipientId: guardian.recipientId,
|
|
77
87
|
faucetId: guardian.faucetId,
|
|
78
88
|
amount: guardian.amount,
|
|
89
|
+
noteType: guardian.noteType,
|
|
79
90
|
};
|
|
80
91
|
case 'consume_notes':
|
|
81
92
|
if (!guardian.noteIds || guardian.noteIds.length === 0) {
|
|
@@ -168,6 +179,9 @@ export class ProposalMetadataCodec {
|
|
|
168
179
|
if (!metadata.recipientId || !metadata.faucetId || !metadata.amount) {
|
|
169
180
|
throw new Error('p2id proposal metadata is incomplete');
|
|
170
181
|
}
|
|
182
|
+
if (metadata.noteType !== undefined && !isP2idNoteVisibility(metadata.noteType)) {
|
|
183
|
+
throw new Error(`p2id proposal has unsupported noteType '${metadata.noteType}'`);
|
|
184
|
+
}
|
|
171
185
|
return metadata;
|
|
172
186
|
case 'custom':
|
|
173
187
|
// Custom proposals are opaque to the SDK; nothing to validate beyond
|