@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.
Files changed (151) hide show
  1. package/README.md +171 -46
  2. package/dist/account/builder.d.ts +4 -4
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +17 -7
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/layout.d.ts +5 -5
  7. package/dist/account/layout.d.ts.map +1 -1
  8. package/dist/account/layout.js +5 -5
  9. package/dist/account/layout.js.map +1 -1
  10. package/dist/client.d.ts +18 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +79 -6
  13. package/dist/client.js.map +1 -1
  14. package/dist/index.d.ts +6 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/multisig/authArgErrors.d.ts +28 -31
  19. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  20. package/dist/multisig/authArgErrors.js +42 -46
  21. package/dist/multisig/authArgErrors.js.map +1 -1
  22. package/dist/multisig/consumeNotesErrors.d.ts +13 -1
  23. package/dist/multisig/consumeNotesErrors.d.ts.map +1 -1
  24. package/dist/multisig/consumeNotesErrors.js +17 -0
  25. package/dist/multisig/consumeNotesErrors.js.map +1 -1
  26. package/dist/multisig/signing.d.ts +1 -1
  27. package/dist/multisig/signing.d.ts.map +1 -1
  28. package/dist/multisig/signing.js +8 -3
  29. package/dist/multisig/signing.js.map +1 -1
  30. package/dist/multisig.d.ts +145 -18
  31. package/dist/multisig.d.ts.map +1 -1
  32. package/dist/multisig.js +493 -162
  33. package/dist/multisig.js.map +1 -1
  34. package/dist/procedures.d.ts +7 -7
  35. package/dist/procedures.js +7 -7
  36. package/dist/proposal/factory.d.ts.map +1 -1
  37. package/dist/proposal/factory.js +7 -0
  38. package/dist/proposal/factory.js.map +1 -1
  39. package/dist/prover/workflow.d.ts +7 -3
  40. package/dist/prover/workflow.d.ts.map +1 -1
  41. package/dist/prover/workflow.js +7 -5
  42. package/dist/prover/workflow.js.map +1 -1
  43. package/dist/raw-client.d.ts +1 -0
  44. package/dist/raw-client.d.ts.map +1 -1
  45. package/dist/raw-client.js +10 -2
  46. package/dist/raw-client.js.map +1 -1
  47. package/dist/recovery/publicNoteBackfill.js +1 -1
  48. package/dist/recovery/publicNoteBackfill.js.map +1 -1
  49. package/dist/retry/classify.d.ts +3 -0
  50. package/dist/retry/classify.d.ts.map +1 -1
  51. package/dist/retry/classify.js +2 -2
  52. package/dist/retry/classify.js.map +1 -1
  53. package/dist/signer.d.ts +1 -0
  54. package/dist/signer.d.ts.map +1 -1
  55. package/dist/signer.js +1 -0
  56. package/dist/signer.js.map +1 -1
  57. package/dist/signers/index.d.ts +1 -0
  58. package/dist/signers/index.d.ts.map +1 -1
  59. package/dist/signers/index.js +1 -0
  60. package/dist/signers/index.js.map +1 -1
  61. package/dist/signers/ledger.d.ts +25 -0
  62. package/dist/signers/ledger.d.ts.map +1 -0
  63. package/dist/signers/ledger.js +96 -0
  64. package/dist/signers/ledger.js.map +1 -0
  65. package/dist/state/adopt.d.ts +45 -0
  66. package/dist/state/adopt.d.ts.map +1 -0
  67. package/dist/state/adopt.js +101 -0
  68. package/dist/state/adopt.js.map +1 -0
  69. package/dist/transaction/authArgs.d.ts +57 -0
  70. package/dist/transaction/authArgs.d.ts.map +1 -0
  71. package/dist/transaction/authArgs.js +108 -0
  72. package/dist/transaction/authArgs.js.map +1 -0
  73. package/dist/transaction/consumeNotes.d.ts +9 -5
  74. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  75. package/dist/transaction/consumeNotes.js +8 -23
  76. package/dist/transaction/consumeNotes.js.map +1 -1
  77. package/dist/transaction/noteAuthentication.d.ts +39 -0
  78. package/dist/transaction/noteAuthentication.d.ts.map +1 -0
  79. package/dist/transaction/noteAuthentication.js +94 -0
  80. package/dist/transaction/noteAuthentication.js.map +1 -0
  81. package/dist/transaction/options.d.ts +25 -0
  82. package/dist/transaction/options.d.ts.map +1 -1
  83. package/dist/transaction/p2id.d.ts +3 -2
  84. package/dist/transaction/p2id.d.ts.map +1 -1
  85. package/dist/transaction/p2id.js +36 -27
  86. package/dist/transaction/p2id.js.map +1 -1
  87. package/dist/transaction/summary.d.ts +126 -22
  88. package/dist/transaction/summary.d.ts.map +1 -1
  89. package/dist/transaction/summary.js +164 -22
  90. package/dist/transaction/summary.js.map +1 -1
  91. package/dist/transaction/updateGuardian.d.ts +3 -3
  92. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  93. package/dist/transaction/updateGuardian.js +5 -16
  94. package/dist/transaction/updateGuardian.js.map +1 -1
  95. package/dist/transaction/updateProcedureThreshold.d.ts +3 -3
  96. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  97. package/dist/transaction/updateProcedureThreshold.js +6 -16
  98. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  99. package/dist/transaction/updateSigners.d.ts +3 -3
  100. package/dist/transaction/updateSigners.d.ts.map +1 -1
  101. package/dist/transaction/updateSigners.js +9 -16
  102. package/dist/transaction/updateSigners.js.map +1 -1
  103. package/dist/transaction.d.ts +3 -2
  104. package/dist/transaction.d.ts.map +1 -1
  105. package/dist/transaction.js +3 -2
  106. package/dist/transaction.js.map +1 -1
  107. package/dist/types/proposal.d.ts +37 -5
  108. package/dist/types/proposal.d.ts.map +1 -1
  109. package/dist/types/proposal.js +8 -0
  110. package/dist/types/proposal.js.map +1 -1
  111. package/dist/utils/eip712.d.ts +80 -0
  112. package/dist/utils/eip712.d.ts.map +1 -0
  113. package/dist/utils/eip712.js +49 -0
  114. package/dist/utils/eip712.js.map +1 -0
  115. package/dist/utils/signature.d.ts +4 -0
  116. package/dist/utils/signature.d.ts.map +1 -1
  117. package/dist/utils/signature.js +49 -1
  118. package/dist/utils/signature.js.map +1 -1
  119. package/package.json +11 -6
  120. package/src/account/builder.ts +18 -7
  121. package/src/account/layout.ts +5 -5
  122. package/src/client.ts +94 -6
  123. package/src/index.ts +24 -3
  124. package/src/multisig/authArgErrors.ts +47 -53
  125. package/src/multisig/consumeNotesErrors.ts +20 -1
  126. package/src/multisig/signing.ts +8 -2
  127. package/src/multisig.ts +614 -205
  128. package/src/procedures.ts +7 -7
  129. package/src/proposal/factory.ts +7 -0
  130. package/src/prover/workflow.ts +7 -10
  131. package/src/raw-client.ts +11 -7
  132. package/src/recovery/publicNoteBackfill.ts +1 -1
  133. package/src/retry/classify.ts +3 -3
  134. package/src/signer.ts +1 -0
  135. package/src/signers/index.ts +1 -0
  136. package/src/signers/ledger.ts +122 -0
  137. package/src/state/adopt.ts +132 -0
  138. package/src/transaction/authArgs.ts +142 -0
  139. package/src/transaction/consumeNotes.ts +23 -30
  140. package/src/transaction/noteAuthentication.ts +136 -0
  141. package/src/transaction/options.ts +27 -0
  142. package/src/transaction/p2id.ts +45 -34
  143. package/src/transaction/summary.ts +239 -30
  144. package/src/transaction/updateGuardian.ts +8 -22
  145. package/src/transaction/updateProcedureThreshold.ts +8 -20
  146. package/src/transaction/updateSigners.ts +11 -22
  147. package/src/transaction.ts +18 -1
  148. package/src/types/proposal.ts +36 -5
  149. package/src/utils/eip712.ts +57 -0
  150. package/src/utils/signature.ts +57 -0
  151. package/src/prover/test-node.d.ts +0 -6
package/src/procedures.ts CHANGED
@@ -11,12 +11,12 @@
11
11
  * human-readable encoding and should not be copied into this table.
12
12
  */
13
13
  export const PROCEDURE_ROOTS = {
14
- update_signers: '0xa261cfd3c8791ac5abe1e78e14eade2f20789d73ab1c23c430418de59bc3380e',
15
- update_procedure_threshold: '0x97587c61d49313b1d5a3c8b7437e0080e67ed9bd9d3e7206bcae562f934ccd03',
16
- auth_tx: '0x43fb07d62ed26993b7b13c7b411db62c5b5acffa2813e989608c41a72d7185ec',
17
- update_guardian: '0x0a614ff7c81a561cbd2a4c2d9482031a7a841ca5de33349daed23a9d871b3675',
18
- send_asset: '0x595bc83258726a66bd904912cfd5186c07cbd902dfbc115b7d6bc8105efc57e3',
19
- receive_asset: '0x34a56dd18f6fe5aab63198b9dcfc6467e793ebabb37d56b994b902504635da13',
14
+ update_signers: '0xe39b380d435dd42206fd625fcddfd26a379a2312cfef0271e9b5f18cbdec67e5',
15
+ update_procedure_threshold: '0x5de3563f30c5dd130da49c8fdd86d867fed6dbbd928363021c53ef99d8034bac',
16
+ auth_tx: '0xf988ff88c7a9c2104d77862d580239ec40e060a9b4d2d96028135abeb8cc58bc',
17
+ update_guardian: '0x93dedb135fd5bb7112c07aacf4a5680ddc76e45ecf043bfc42ea735b6d971911',
18
+ send_asset: '0x936e9920bffd7f458cc9ba2c4bbcc018fc4d3561511d79635955129268039dd7',
19
+ receive_asset: '0xd7416b798a70aabbca510c3cd0f48ba35473b5d76dc302375157c6f563fffc15',
20
20
  } as const;
21
21
 
22
22
  /**
@@ -33,7 +33,7 @@ export type ProcedureName = keyof typeof PROCEDURE_ROOTS;
33
33
  * @example
34
34
  * ```typescript
35
35
  * const root = getProcedureRoot('send_asset');
36
- * // '0x6d30df4312a2c44ec842db1bee227cc045396ca91e2c47d756dcb607f2bf5f89'
36
+ * // '0x936e9920bffd7f458cc9ba2c4bbcc018fc4d3561511d79635955129268039dd7'
37
37
  * ```
38
38
  */
39
39
  export function getProcedureRoot(name: ProcedureName): string {
@@ -92,6 +92,7 @@ export class ProposalFactory {
92
92
  txSummary: delta.deltaPayload.txSummary.data,
93
93
  signatures,
94
94
  metadata: resolvedMetadata,
95
+ verification: { status: 'unchecked' },
95
96
  };
96
97
  }
97
98
 
@@ -117,6 +118,10 @@ export class ProposalFactory {
117
118
  `Invalid imported proposal signatures: ECDSA signature for ${signature.commitment} is missing publicKey`,
118
119
  );
119
120
  }
121
+ if (signature.messageFormat !== undefined &&
122
+ (scheme !== 'ecdsa' || signature.messageFormat !== 'eip712')) {
123
+ throw new Error('Invalid imported proposal signatures: unsupported message format');
124
+ }
120
125
 
121
126
  return {
122
127
  signerId: signature.commitment,
@@ -126,6 +131,7 @@ export class ProposalFactory {
126
131
  scheme,
127
132
  signature: signature.signatureHex,
128
133
  publicKey: signature.publicKey,
134
+ ...(signature.messageFormat ? { messageFormat: signature.messageFormat } : {}),
129
135
  }
130
136
  : {
131
137
  scheme,
@@ -148,6 +154,7 @@ export class ProposalFactory {
148
154
  txSummary: exported.txSummaryBase64,
149
155
  signatures,
150
156
  metadata,
157
+ verification: { status: 'unchecked' },
151
158
  };
152
159
  }
153
160
 
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  AccountId,
3
- ChainAnchor,
4
3
  MidenClient,
5
4
  TransactionRequest,
6
5
  } from '@miden-sdk/miden-sdk';
@@ -15,15 +14,13 @@ export class ProverWorkflow {
15
14
  private readonly runtime?: RetryRuntime,
16
15
  ) {}
17
16
 
18
- /** Proves, submits, and applies a request at its signed chain anchor. */
19
- async submitAt(
20
- accountId: AccountId,
21
- request: TransactionRequest,
22
- anchor: ChainAnchor,
23
- ): Promise<void> {
24
- const execution = await this.client.transactions.executeRequest(accountId, request, {
25
- anchor,
26
- });
17
+ /**
18
+ * Executes a request at the chain tip, then proves, submits, and applies it.
19
+ * A multisig proposal's request declares the block its summary binds, so the
20
+ * signed summary reproduces at the tip.
21
+ */
22
+ async submit(accountId: AccountId, request: TransactionRequest): Promise<void> {
23
+ const execution = await this.client.transactions.executeRequest(accountId, request);
27
24
  const proof = await proveWithRetry(execution, this.config, this.runtime);
28
25
  const submission = await proof.submit();
29
26
  await submission.apply();
package/src/raw-client.ts CHANGED
@@ -29,7 +29,7 @@ export function requireMidenRpcEndpoint(endpoint?: string): string {
29
29
  return requireConfigValue('midenRpcEndpoint', endpoint);
30
30
  }
31
31
 
32
- function isPublicMidenClient(client: RawClientSource): client is MidenClient {
32
+ export function isPublicMidenClient(client: RawClientSource): client is MidenClient {
33
33
  return 'accounts' in client && 'sync' in client;
34
34
  }
35
35
 
@@ -47,16 +47,20 @@ export async function getRawMidenClient(
47
47
  }
48
48
 
49
49
  const endpoint = requireMidenRpcEndpoint(rpcUrl);
50
- const rawClient = WasmWebClient.createClient(
51
- endpoint,
52
- undefined,
53
- undefined,
54
- await client.storeIdentifier(),
55
- );
50
+ const rawClient = createRawClient(client, endpoint);
56
51
  rawClientCache.set(client, rawClient);
57
52
  return rawClient;
58
53
  }
59
54
 
55
+ /**
56
+ * Opens the WASM client behind `client` on the same store. The protocol
57
+ * configuration comes from the node with every sync and is read from that
58
+ * shared store, so the shadow needs no fee faucet of its own.
59
+ */
60
+ async function createRawClient(client: MidenClient, endpoint: string): Promise<WasmWebClient> {
61
+ return WasmWebClient.createClient(endpoint, undefined, undefined, await client.storeIdentifier());
62
+ }
63
+
60
64
  export function getTransactionProver(client: RawClientSource): TransactionProver | null {
61
65
  return isPublicMidenClient(client) ? client.defaultProver : null;
62
66
  }
@@ -102,7 +102,7 @@ export function screenNoteForAccount(note: Note, account: AccountId): ScreenVerd
102
102
  items[index].asInt() === suffix &&
103
103
  items[index + 1].asInt() === prefix;
104
104
  if (root === normalizeHexWord(NoteScript.p2id().root().toHex())) {
105
- // P2ID note storage: [target.suffix, target.prefix].
105
+ // P2ID note storage: [target.suffix, target.prefix, salt_0, salt_1].
106
106
  return accountAt(0) ? 'relevant' : 'irrelevant';
107
107
  }
108
108
  if (root === normalizeHexWord(NoteScript.p2ide().root().toHex())) {
@@ -15,7 +15,7 @@ type ErrorRecord = {
15
15
  message?: unknown;
16
16
  };
17
17
 
18
- type StructuredEvidence = 'transient' | 'permanent' | 'indeterminate';
18
+ export type StructuredEvidence = 'transient' | 'permanent' | 'indeterminate';
19
19
 
20
20
  const TRANSIENT_GRPC = new Set([
21
21
  'cancelled',
@@ -96,7 +96,7 @@ function httpEvidence(value: unknown): StructuredEvidence | undefined {
96
96
  return TRANSIENT_HTTP.has(value) ? 'transient' : 'permanent';
97
97
  }
98
98
 
99
- function httpMessageEvidence(message: string): StructuredEvidence | undefined {
99
+ export function httpMessageEvidence(message: string): StructuredEvidence | undefined {
100
100
  let hasTransient = false;
101
101
  for (const match of message.matchAll(
102
102
  /(?:\bhttp(?:\s+status)?|\bstatus)(?:\s+code)?\s*:?\s*(\d{3})\b/g,
@@ -110,7 +110,7 @@ function httpMessageEvidence(message: string): StructuredEvidence | undefined {
110
110
  return hasTransient ? 'transient' : undefined;
111
111
  }
112
112
 
113
- function grpcMessageEvidence(message: string): StructuredEvidence | undefined {
113
+ export function grpcMessageEvidence(message: string): StructuredEvidence | undefined {
114
114
  const normalized = message.replaceAll(/[^a-z0-9]/g, '');
115
115
  let hasTransient = false;
116
116
  for (const { token, evidence } of GRPC_MESSAGE_TOKENS) {
package/src/signer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { FalconSigner } from './signers/falcon.js';
2
2
  export { EcdsaSigner } from './signers/ecdsa.js';
3
+ export { Eip712Signer, LedgerSigner, type Eip1193SignerProvider } from './signers/ledger.js';
3
4
  export { ParaSigner, type ParaSigningContext } from './signers/para.js';
4
5
  export { MidenWalletSigner, type WalletSigningContext } from './signers/miden-wallet.js';
@@ -1,4 +1,5 @@
1
1
  export { FalconSigner } from './falcon.js';
2
2
  export { EcdsaSigner } from './ecdsa.js';
3
+ export { Eip712Signer, LedgerSigner, type Eip1193SignerProvider } from './ledger.js';
3
4
  export { ParaSigner, type ParaSigningContext } from './para.js';
4
5
  export { MidenWalletSigner, type WalletSigningContext } from './miden-wallet.js';
@@ -0,0 +1,122 @@
1
+ import { secp256k1 } from '@noble/curves/secp256k1';
2
+ import { keccak_256 } from '@noble/hashes/sha3.js';
3
+ import type { RequestAuthPayload, Signer } from '@openzeppelin/guardian-client';
4
+ import { AuthDigest } from '../utils/digest.js';
5
+ import { lookupAuthDigest } from '../lookupAuth.js';
6
+ import { EcdsaFormat } from '../utils/ecdsa.js';
7
+ import { bytesToHex, hexToBytes } from '../utils/encoding.js';
8
+ import {
9
+ guardianKeyDiscoveryTypedData,
10
+ guardianLookupTypedData,
11
+ guardianRequestTypedData,
12
+ midenTransactionTypedData,
13
+ typedDataDigest,
14
+ } from '../utils/eip712.js';
15
+ import { tryComputeEcdsaCommitmentHex } from '../utils/signature.js';
16
+ import { wordToBytes } from '../utils/word.js';
17
+
18
+ export interface Eip1193SignerProvider {
19
+ request(args: { method: string; params: unknown[] }): Promise<unknown>;
20
+ }
21
+
22
+ export class Eip712Signer implements Signer {
23
+ readonly scheme = 'ecdsa';
24
+ readonly requestAuthFormat = 'eip712';
25
+ readonly proposalMessageFormat = 'eip712';
26
+ readonly publicKey: string;
27
+ readonly commitment: string;
28
+ readonly address: string;
29
+
30
+ static async connect(provider: Eip1193SignerProvider): Promise<Eip712Signer> {
31
+ const accounts = await provider.request({ method: 'eth_requestAccounts', params: [] });
32
+ if (!Array.isArray(accounts) || typeof accounts[0] !== 'string') {
33
+ throw new Error('Wallet did not provide an Ethereum address');
34
+ }
35
+ const address = accounts[0];
36
+ const challenge = crypto.getRandomValues(new Uint8Array(32));
37
+ const data = guardianKeyDiscoveryTypedData(challenge);
38
+ const result = await provider.request({
39
+ method: 'eth_signTypedData_v4',
40
+ params: [address, JSON.stringify(data)],
41
+ });
42
+ if (typeof result !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(result)) {
43
+ throw new Error('Wallet returned an invalid key-discovery signature');
44
+ }
45
+ const signature = hexToBytes(EcdsaFormat.normalizeRecoveryByte(result));
46
+ if (signature[64] !== 0 && signature[64] !== 1) {
47
+ throw new Error('Wallet returned an invalid recovery ID');
48
+ }
49
+ const publicKey = secp256k1.Signature.fromCompact(signature.slice(0, 64))
50
+ .addRecoveryBit(signature[64])
51
+ .recoverPublicKey(typedDataDigest(data))
52
+ .toRawBytes(true);
53
+ return new Eip712Signer(provider, bytesToHex(publicKey), address);
54
+ }
55
+
56
+ constructor(
57
+ private readonly provider: Eip1193SignerProvider,
58
+ publicKey: string,
59
+ address: string,
60
+ ) {
61
+ if (!EcdsaFormat.isValidPublicKeyPoint(publicKey)) {
62
+ throw new Error('Invalid wallet public key');
63
+ }
64
+ this.publicKey = EcdsaFormat.compressPublicKey(publicKey);
65
+ const commitment = tryComputeEcdsaCommitmentHex(this.publicKey);
66
+ if (!commitment) {
67
+ throw new Error('Cannot derive the Miden commitment for the wallet public key');
68
+ }
69
+ this.commitment = commitment;
70
+
71
+ const uncompressed = secp256k1.ProjectivePoint.fromHex(this.publicKey.slice(2)).toRawBytes(false);
72
+ const derivedAddress = bytesToHex(keccak_256(uncompressed.slice(1)).slice(-20));
73
+ if (derivedAddress.toLowerCase() !== address.toLowerCase()) {
74
+ throw new Error('Wallet address does not match its public key');
75
+ }
76
+ this.address = address;
77
+ }
78
+
79
+ signAccountIdWithTimestamp(): Promise<string> {
80
+ throw new Error('Eip712Signer requires request-bound Guardian authentication');
81
+ }
82
+
83
+ async signRequest(
84
+ accountId: string,
85
+ timestamp: number,
86
+ requestPayload: RequestAuthPayload,
87
+ ): Promise<string> {
88
+ const requestHash = AuthDigest.fromRequest(accountId, timestamp, requestPayload);
89
+ return this.signTypedData(guardianRequestTypedData(wordToBytes(requestHash)));
90
+ }
91
+
92
+ async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
93
+ const lookupHash = lookupAuthDigest(timestampMs, keyCommitmentHex);
94
+ return this.signTypedData(guardianLookupTypedData(wordToBytes(lookupHash)));
95
+ }
96
+
97
+ async signCommitment(commitmentHex: string): Promise<string> {
98
+ const commitment = AuthDigest.fromCommitmentHex(commitmentHex);
99
+ return this.signTypedData(midenTransactionTypedData(wordToBytes(commitment)));
100
+ }
101
+
102
+ private async signTypedData(data: ReturnType<typeof guardianRequestTypedData>): Promise<string> {
103
+ const result = await this.provider.request({
104
+ method: 'eth_signTypedData_v4',
105
+ params: [this.address, JSON.stringify(data)],
106
+ });
107
+ if (typeof result !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(result)) {
108
+ throw new Error('Wallet returned an invalid EIP-712 signature');
109
+ }
110
+ const signatureHex = EcdsaFormat.normalizeRecoveryByte(result);
111
+ const signature = hexToBytes(signatureHex);
112
+ if (signature[64] !== 0 && signature[64] !== 1) {
113
+ throw new Error('Wallet returned an invalid ECDSA recovery ID');
114
+ }
115
+ if (!secp256k1.verify(signature.slice(0, 64), typedDataDigest(data), hexToBytes(this.publicKey))) {
116
+ throw new Error('Wallet signature was produced by a different key');
117
+ }
118
+ return signatureHex;
119
+ }
120
+ }
121
+
122
+ export { Eip712Signer as LedgerSigner };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Deciding when GUARDIAN-provided account state may replace the local store.
3
+ *
4
+ * Both entry points that pull an account from GUARDIAN need this: `syncState`,
5
+ * which refreshes an account already in hand, and `MultisigClient.load`, which
6
+ * rebuilds one. Sharing the rule is the point. Deciding it twice is how the two
7
+ * paths came to disagree, with `load` keeping whatever the store held whenever
8
+ * it held anything, so a caller that had touched the account before read stale
9
+ * state with no error.
10
+ */
11
+
12
+ import { Account, AccountId, Endpoint, RpcClient } from '@miden-sdk/miden-sdk';
13
+
14
+ import { retryRpcRead } from '../rpc/retry.js';
15
+ import type { ResolvedRpcConfig } from '../rpc/config.js';
16
+ import { normalizeHexWord } from '../utils/encoding.js';
17
+
18
+ const ZERO_COMMITMENT = `0x${'0'.repeat(64)}`;
19
+
20
+ /**
21
+ * The account's commitment as the node reports it, or `null` when the account
22
+ * is not deployed yet. An undeployed account is not an error: it has no
23
+ * on-chain commitment to disagree with.
24
+ */
25
+ export async function readOnChainCommitment(
26
+ midenRpcEndpoint: string,
27
+ accountId: AccountId,
28
+ rpcConfig: ResolvedRpcConfig,
29
+ ): Promise<string | null> {
30
+ const rpcClient = new RpcClient(new Endpoint(midenRpcEndpoint));
31
+
32
+ try {
33
+ const accountDetails = await retryRpcRead(
34
+ () => rpcClient.getAccountDetails(accountId),
35
+ rpcConfig,
36
+ );
37
+ if (!accountDetails) {
38
+ return null;
39
+ }
40
+ const commitment = normalizeHexWord(accountDetails.commitment().toHex());
41
+ return commitment === ZERO_COMMITMENT ? null : commitment;
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ if (
45
+ message.includes('null pointer passed to rust') ||
46
+ message.includes('No account header record found for given ID') ||
47
+ message.toLowerCase().includes('not found')
48
+ ) {
49
+ return null;
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Decide whether GUARDIAN-provided state may overwrite the local store.
57
+ *
58
+ * Returns `false`, rather than throwing, when the GUARDIAN state is simply
59
+ * *behind* local (lower nonce). That happens whenever the execution delta the
60
+ * client pushed has not been canonicalized by GUARDIAN's background worker yet
61
+ * (see OpenZeppelin/guardian#316), or permanently if that candidate was
62
+ * discarded (#312 / #319). In that case the local account is already ahead and
63
+ * is independently verifiable against chain (`verifyStateCommitment`), so it is
64
+ * authoritative and must be kept, not clobbered; the caller keeps local and
65
+ * refreshes config from it.
66
+ *
67
+ * Still throws for genuine divergence: an incoming state at the *same* nonce as
68
+ * local but a different commitment, or an incoming state whose commitment does
69
+ * not match the on-chain commitment.
70
+ *
71
+ * Callers must only reach here once they know the two states differ. At equal
72
+ * nonce this treats the pair as divergent, which is true only when the
73
+ * commitments already disagree.
74
+ */
75
+ export async function isSafeToAdoptGuardianState(params: {
76
+ accountId: string;
77
+ incomingAccount: Account;
78
+ localAccount?: Account;
79
+ readCommitment: () => Promise<string | null>;
80
+ }): Promise<boolean> {
81
+ const { accountId, incomingAccount, localAccount, readCommitment } = params;
82
+
83
+ if (localAccount) {
84
+ const localNonce = localAccount.nonce().asInt();
85
+ const incomingNonce = incomingAccount.nonce().asInt();
86
+
87
+ if (incomingNonce < localNonce) {
88
+ return false;
89
+ }
90
+
91
+ if (incomingNonce === localNonce) {
92
+ throw new Error(
93
+ `Refusing to overwrite local state: incoming nonce ${incomingNonce.toString()} equals local nonce ${localNonce.toString()} but commitments differ for account ${accountId}`
94
+ );
95
+ }
96
+ }
97
+
98
+ const onChainCommitment = await readCommitment();
99
+ if (!onChainCommitment) {
100
+ // No on-chain commitment means the account is not deployed, and an
101
+ // undeployed account has nothing to disagree with. That reading is only
102
+ // safe when the account has never transacted: `readOnChainCommitment`
103
+ // reports a missing account by matching `not found` in the error text, and
104
+ // a proxy or gateway 404 says exactly that. An account with a non-zero
105
+ // nonce has transacted, so it is deployed, so this is an RPC failure rather
106
+ // than an undeployed account, and adopting on it would skip the commitment
107
+ // check entirely.
108
+ // Keyed on the *local* nonce only. An account this client has transacted is
109
+ // deployed, so a node reporting nothing for it is an RPC failure rather
110
+ // than an undeployed account, and adopting there would skip the check
111
+ // against chain. The incoming nonce is GUARDIAN's claim rather than
112
+ // evidence, and `syncState` legitimately meets a higher incoming nonce with
113
+ // no local history; the caller that has no local record at all applies its
114
+ // own rule.
115
+ const transacted = localAccount?.nonce().asInt() ?? BigInt(0);
116
+ if (transacted > BigInt(0)) {
117
+ throw new Error(
118
+ `Refusing to overwrite local state: account ${accountId} has transacted (nonce ${transacted.toString()}) but the node reported no on-chain commitment, so the incoming state could not be checked against chain`
119
+ );
120
+ }
121
+ return true;
122
+ }
123
+
124
+ const incomingCommitment = normalizeHexWord(incomingAccount.to_commitment().toHex());
125
+ if (incomingCommitment !== onChainCommitment) {
126
+ throw new Error(
127
+ `Refusing to overwrite local state: incoming commitment does not match on-chain commitment for account ${accountId}`
128
+ );
129
+ }
130
+
131
+ return true;
132
+ }
@@ -0,0 +1,142 @@
1
+ import type { Felt, TransactionRequest, TransactionRequestBuilder, Word } from '@miden-sdk/miden-sdk';
2
+ import { AccountId, Word as WordType } from '@miden-sdk/miden-sdk';
3
+ import { MultisigAuthArgsMissingError } from '../multisig/authArgErrors.js';
4
+ import { getRawMidenClient, isPublicMidenClient, type RawClientSource } from '../raw-client.js';
5
+ import { normalizeHexWord } from '../utils/encoding.js';
6
+ import { randomWord } from '../utils/random.js';
7
+ import type { MultisigRequestOptions } from './options.js';
8
+
9
+ /**
10
+ * Layout of the multisig auth-args preimage the auth arg commits to, in felts:
11
+ * `[bound_block_num, approval_expiration_block_num, 0, 0, SALT, CONVERSION_INFO]`.
12
+ */
13
+ const AUTH_ARGS_BOUND_BLOCK_INDEX = 0;
14
+ const AUTH_ARGS_SALT_OFFSET = 4;
15
+ const AUTH_ARGS_NUM_ELEMENTS = 12;
16
+
17
+ /**
18
+ * A request under construction that already carries the multisig auth args,
19
+ * and the salt they commit to as normalized hex.
20
+ */
21
+ export interface MultisigRequestDraft {
22
+ builder: TransactionRequestBuilder;
23
+ saltHex: string;
24
+ }
25
+
26
+ /**
27
+ * Starts a request carrying the multisig auth args for `options.accountId`:
28
+ * the three-word preimage in the advice map and its commitment as the auth
29
+ * arg, which miden-client then leaves alone.
30
+ *
31
+ * The salt is `options.salt` or a fresh one. `approvalExpirationDelta` left
32
+ * out means the approval never expires, the upstream default. `boundBlockNum`
33
+ * left out binds the store's sync height, which is what a proposer wants; a
34
+ * rebuild pins the proposal's anchor block and the expiration the summary
35
+ * already binds.
36
+ *
37
+ * The salt is moved across the WASM boundary, so a handle is built here from
38
+ * the hex rather than taken from the caller, and nothing frees it afterwards.
39
+ */
40
+ export async function multisigRequestBuilder(
41
+ client: RawClientSource,
42
+ options: MultisigRequestOptions,
43
+ ): Promise<MultisigRequestDraft> {
44
+ const { accountId, boundBlockNum, approvalExpirationDelta, midenRpcEndpoint } = options;
45
+ assertApprovalExpirationDelta(approvalExpirationDelta);
46
+ const saltHex = normalizeHexWord(options.salt ? options.salt.toHex() : randomWord().toHex());
47
+ const salt = WordType.fromHex(saltHex);
48
+ if (isPublicMidenClient(client)) {
49
+ const builder = await client.feeAwareTransactionRequestBuilder(accountId, {
50
+ feeConversionSalt: salt,
51
+ boundBlockNum,
52
+ approvalExpirationDelta,
53
+ });
54
+ return { builder, saltHex };
55
+ }
56
+ const rawClient = await getRawMidenClient(client, midenRpcEndpoint);
57
+ const builder = await rawClient.feeAwareTransactionRequestBuilder(
58
+ AccountId.fromHex(accountId),
59
+ approvalExpirationDelta ?? null,
60
+ salt,
61
+ boundBlockNum ?? null,
62
+ );
63
+ return { builder, saltHex };
64
+ }
65
+
66
+ /**
67
+ * The furthest a transaction may expire after its reference block
68
+ * (`MAX_EXPIRATION_BLOCK_DELTA` in the transaction kernel). The auth procedure
69
+ * clamps the approval expiration it applies to this, so a longer approval would
70
+ * outlive the transaction it authorizes: the summary would still say "valid"
71
+ * while the node already refuses the submission as expired.
72
+ */
73
+ export const MAX_APPROVAL_EXPIRATION_DELTA = 65_535;
74
+
75
+ /**
76
+ * Zero is not "no expiration": the kernel reads it as expired at the bound
77
+ * block and rejects the transaction. Refused here, where the caller can see why,
78
+ * together with a delta the auth procedure would silently clamp.
79
+ */
80
+ function assertApprovalExpirationDelta(delta: number | undefined): void {
81
+ if (delta === undefined) {
82
+ return;
83
+ }
84
+ if (!Number.isInteger(delta) || delta < 1 || delta > MAX_APPROVAL_EXPIRATION_DELTA) {
85
+ throw new Error(
86
+ `approvalExpirationDelta must be a whole number of blocks between 1 and ${MAX_APPROVAL_EXPIRATION_DELTA}, ` +
87
+ `got ${delta}; omit it for an approval that does not expire`,
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Builds the request and hands back the salt it commits to as a fresh handle
94
+ * the caller owns. Refuses a request that carries no auth arg:
95
+ * `feeAwareTransactionRequestBuilder` hands back an untouched builder for an
96
+ * account it cannot classify as a multisig, typically one the client's store
97
+ * does not hold, and such a request would only fail later, inside the VM,
98
+ * while the auth procedure pipes a preimage that is not there.
99
+ */
100
+ export function buildMultisigRequest(
101
+ builder: TransactionRequestBuilder,
102
+ saltHex: string,
103
+ accountId: string,
104
+ ): { request: TransactionRequest; salt: Word } {
105
+ const request = builder.build();
106
+ if (!request.authArg()) {
107
+ throw new MultisigAuthArgsMissingError(accountId);
108
+ }
109
+ return { request, salt: WordType.fromHex(saltHex) };
110
+ }
111
+
112
+ /**
113
+ * The block a multisig request's summary binds, read from the auth-args
114
+ * preimage the request carries. `undefined` when the request carries none.
115
+ */
116
+ export function requestBoundBlockNum(request: TransactionRequest): number | undefined {
117
+ const preimage = authArgsPreimage(request);
118
+ return preimage ? Number(preimage[AUTH_ARGS_BOUND_BLOCK_INDEX].asInt()) : undefined;
119
+ }
120
+
121
+ /**
122
+ * The salt a multisig request's summary binds, read from the auth-args
123
+ * preimage the request carries. `undefined` when the request carries none.
124
+ */
125
+ export function requestSaltHex(request: TransactionRequest): string | undefined {
126
+ const preimage = authArgsPreimage(request);
127
+ if (!preimage) {
128
+ return undefined;
129
+ }
130
+ return WordType.newFromFelts(
131
+ preimage.slice(AUTH_ARGS_SALT_OFFSET, AUTH_ARGS_SALT_OFFSET + 4),
132
+ ).toHex();
133
+ }
134
+
135
+ function authArgsPreimage(request: TransactionRequest): Felt[] | undefined {
136
+ const authArg = request.authArg();
137
+ if (!authArg) {
138
+ return undefined;
139
+ }
140
+ const felts = request.adviceMap().get(authArg);
141
+ return felts && felts.length === AUTH_ARGS_NUM_ELEMENTS ? felts : undefined;
142
+ }