@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.
Files changed (104) hide show
  1. package/README.md +117 -25
  2. package/dist/client.d.ts +39 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +77 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/index.d.ts +5 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/lookupAuth.d.ts.map +1 -1
  11. package/dist/lookupAuth.js +2 -20
  12. package/dist/lookupAuth.js.map +1 -1
  13. package/dist/multisig/authArgErrors.d.ts +27 -1
  14. package/dist/multisig/authArgErrors.d.ts.map +1 -1
  15. package/dist/multisig/authArgErrors.js +33 -0
  16. package/dist/multisig/authArgErrors.js.map +1 -1
  17. package/dist/multisig.d.ts +77 -11
  18. package/dist/multisig.d.ts.map +1 -1
  19. package/dist/multisig.js +247 -181
  20. package/dist/multisig.js.map +1 -1
  21. package/dist/proposal/metadata.d.ts.map +1 -1
  22. package/dist/proposal/metadata.js +2 -0
  23. package/dist/proposal/metadata.js.map +1 -1
  24. package/dist/recovery/transportDrain.d.ts +38 -30
  25. package/dist/recovery/transportDrain.d.ts.map +1 -1
  26. package/dist/recovery/transportDrain.js +113 -113
  27. package/dist/recovery/transportDrain.js.map +1 -1
  28. package/dist/session/grant.d.ts +40 -0
  29. package/dist/session/grant.d.ts.map +1 -0
  30. package/dist/session/grant.js +125 -0
  31. package/dist/session/grant.js.map +1 -0
  32. package/dist/session/key.d.ts +103 -0
  33. package/dist/session/key.d.ts.map +1 -0
  34. package/dist/session/key.js +160 -0
  35. package/dist/session/key.js.map +1 -0
  36. package/dist/session/manager.d.ts +76 -0
  37. package/dist/session/manager.d.ts.map +1 -0
  38. package/dist/session/manager.js +187 -0
  39. package/dist/session/manager.js.map +1 -0
  40. package/dist/signers/ecdsa.d.ts +5 -1
  41. package/dist/signers/ecdsa.d.ts.map +1 -1
  42. package/dist/signers/ecdsa.js +9 -0
  43. package/dist/signers/ecdsa.js.map +1 -1
  44. package/dist/signers/falcon.d.ts +5 -1
  45. package/dist/signers/falcon.d.ts.map +1 -1
  46. package/dist/signers/falcon.js +9 -0
  47. package/dist/signers/falcon.js.map +1 -1
  48. package/dist/signers/ledger.d.ts +9 -1
  49. package/dist/signers/ledger.d.ts.map +1 -1
  50. package/dist/signers/ledger.js +13 -1
  51. package/dist/signers/ledger.js.map +1 -1
  52. package/dist/signers/miden-wallet.d.ts +13 -1
  53. package/dist/signers/miden-wallet.d.ts.map +1 -1
  54. package/dist/signers/miden-wallet.js +25 -0
  55. package/dist/signers/miden-wallet.js.map +1 -1
  56. package/dist/signers/para.d.ts +5 -1
  57. package/dist/signers/para.d.ts.map +1 -1
  58. package/dist/signers/para.js +9 -0
  59. package/dist/signers/para.js.map +1 -1
  60. package/dist/transaction/authArgs.d.ts +2 -2
  61. package/dist/transaction/authArgs.js +2 -2
  62. package/dist/transaction/options.d.ts +2 -2
  63. package/dist/transaction/summary.d.ts +17 -61
  64. package/dist/transaction/summary.d.ts.map +1 -1
  65. package/dist/transaction/summary.js +49 -95
  66. package/dist/transaction/summary.js.map +1 -1
  67. package/dist/transaction.d.ts +1 -1
  68. package/dist/transaction.d.ts.map +1 -1
  69. package/dist/transaction.js +1 -1
  70. package/dist/transaction.js.map +1 -1
  71. package/dist/types/proposal.d.ts +11 -5
  72. package/dist/types/proposal.d.ts.map +1 -1
  73. package/dist/types/proposal.js.map +1 -1
  74. package/dist/utils/eip712.d.ts +26 -69
  75. package/dist/utils/eip712.d.ts.map +1 -1
  76. package/dist/utils/eip712.js +101 -8
  77. package/dist/utils/eip712.js.map +1 -1
  78. package/dist/utils/felt.d.ts +7 -1
  79. package/dist/utils/felt.d.ts.map +1 -1
  80. package/dist/utils/felt.js +18 -1
  81. package/dist/utils/felt.js.map +1 -1
  82. package/package.json +4 -4
  83. package/src/client.ts +95 -0
  84. package/src/index.ts +27 -6
  85. package/src/lookupAuth.ts +3 -22
  86. package/src/multisig/authArgErrors.ts +42 -1
  87. package/src/multisig.ts +318 -207
  88. package/src/proposal/metadata.ts +2 -0
  89. package/src/recovery/transportDrain.ts +118 -124
  90. package/src/session/grant.ts +143 -0
  91. package/src/session/key.ts +215 -0
  92. package/src/session/manager.ts +273 -0
  93. package/src/signers/ecdsa.ts +12 -1
  94. package/src/signers/falcon.ts +12 -1
  95. package/src/signers/ledger.ts +19 -2
  96. package/src/signers/miden-wallet.ts +29 -1
  97. package/src/signers/para.ts +12 -1
  98. package/src/transaction/authArgs.ts +2 -2
  99. package/src/transaction/options.ts +2 -2
  100. package/src/transaction/summary.ts +62 -110
  101. package/src/transaction.ts +1 -6
  102. package/src/types/proposal.ts +11 -5
  103. package/src/utils/eip712.ts +130 -10
  104. package/src/utils/felt.ts +19 -1
@@ -8,8 +8,8 @@ export interface SignatureOptions {
8
8
  /**
9
9
  * The block the transaction summary binds. Omitted, the store's sync height,
10
10
  * which is right for the party creating a proposal. A cosigner or executor
11
- * rebuilding a proposal pins it to the proposal's anchor block, or the rebuilt
12
- * summary can never match the signed one.
11
+ * rebuilding a proposal pins it to the block the proposal's summary binds, or
12
+ * the rebuilt summary can never match the signed one.
13
13
  */
14
14
  boundBlockNum?: number;
15
15
  /**
@@ -1,7 +1,9 @@
1
1
  import type { MidenClient, TransactionRequest, TransactionSummary } from '@miden-sdk/miden-sdk';
2
- import { ChainAnchor, Word } from '@miden-sdk/miden-sdk';
3
- import { BoundBlockNotDeclaredError } from '../multisig/authArgErrors.js';
4
- import { base64ToUint8Array, normalizeHexWord, uint8ArrayToBase64 } from '../utils/encoding.js';
2
+ import { Word } from '@miden-sdk/miden-sdk';
3
+ import {
4
+ BoundBlockNotDeclaredError,
5
+ TransactionSummaryLayoutError,
6
+ } from '../multisig/authArgErrors.js';
5
7
  import { requestBoundBlockNum } from './authArgs.js';
6
8
 
7
9
  /**
@@ -13,24 +15,16 @@ const APPROVAL_EXPIRATION_USER_PARAM_INDEX = 0;
13
15
  const SALT_USER_PARAM_OFFSET = 2;
14
16
 
15
17
  /**
16
- * The summary binds the block the request's auth args name, and the anchor
17
- * the store's sync height at capture. A sync landing between the build and the
18
- * capture leaves them one block apart, and every cosigner's anchor check would
19
- * then fail on a proposal nothing else is wrong with. Caught here, before the
20
- * proposal is pushed, so the proposer rebuilds instead.
18
+ * The serialized summary layout {@link summaryBoundBlockNum} reads, from
19
+ * `TransactionSummary::write_into` at miden-protocol 0.17.0: the version byte
20
+ * first, then variable-length fields, then a fixed tail of the block number
21
+ * (u32, little endian), the block commitment (four 8-byte felts), the
22
+ * expiration delta (u16) and the six user-param felts, with no length prefix.
21
23
  */
22
- export class SummaryAnchorMismatchError extends Error {
23
- readonly retryable = true;
24
-
25
- constructor(details: { anchorCommitmentHex: string; summaryBlockCommitmentHex: string }) {
26
- super(
27
- `the transaction summary binds block commitment ${details.summaryBlockCommitmentHex} but ` +
28
- `the captured chain anchor is ${details.anchorCommitmentHex}; a sync landed between ` +
29
- 'building the request and capturing its anchor, so rebuild the request and retry',
30
- );
31
- this.name = 'SummaryAnchorMismatchError';
32
- }
33
- }
24
+ const SUPPORTED_SUMMARY_VERSION = 1;
25
+ const BLOCK_NUMBER_BYTES = 4;
26
+ const BLOCK_COMMITMENT_BYTES = 32;
27
+ const SUMMARY_TAIL_BYTES = BLOCK_NUMBER_BYTES + BLOCK_COMMITMENT_BYTES + 2 + 6 * 8;
34
28
 
35
29
  /**
36
30
  * The Miden client synced and its node still has not produced the block a
@@ -67,57 +61,18 @@ export function isStaleChainError(error: unknown): boolean {
67
61
  return message.includes('has been pruned');
68
62
  }
69
63
 
70
- /**
71
- * Derives the summary awaiting authorization for a proposal the caller is
72
- * creating now, and captures a `ChainAnchor` at the current sync height to ship
73
- * with it.
74
- *
75
- * The summary is derived at the chain tip, like every other execution of a
76
- * multisig proposal (see {@link executeForSummaryAtTip}). The anchor still
77
- * travels in the proposal: it names the block the request's auth args bind,
78
- * which is how a rebuild learns that block, and 0.18.0-rc.1 clients re-execute
79
- * at it. A proposer builds at the sync height the anchor is captured at, and
80
- * the check below is what makes that hold.
81
- */
82
- export async function executeForSummary(
83
- client: MidenClient,
84
- accountId: string,
85
- txRequest: TransactionRequest,
86
- ): Promise<{ summary: TransactionSummary; anchor: ChainAnchor }> {
87
- const anchor = await client.transactions.captureAnchor(txRequest);
88
- let summary: TransactionSummary;
89
- try {
90
- summary = await executeForSummaryAtTip(client, accountId, txRequest);
91
- } catch (error) {
92
- anchor.free();
93
- throw error;
94
- }
95
-
96
- const anchorCommitment = anchor.commitment();
97
- const summaryBlockCommitment = summary.blockCommitment();
98
- const anchorCommitmentHex = normalizeHexWord(anchorCommitment.toHex());
99
- const summaryBlockCommitmentHex = normalizeHexWord(summaryBlockCommitment.toHex());
100
- anchorCommitment.free?.();
101
- summaryBlockCommitment.free?.();
102
- if (anchorCommitmentHex !== summaryBlockCommitmentHex) {
103
- anchor.free();
104
- throw new SummaryAnchorMismatchError({ anchorCommitmentHex, summaryBlockCommitmentHex });
105
- }
106
- return { summary, anchor };
107
- }
108
-
109
64
  /**
110
65
  * Executes a multisig request at the chain tip to obtain the summary awaiting
111
- * authorization. This is how cosigners and the executor reproduce a proposal's
112
- * summary, whatever block they have synced to.
66
+ * authorization. Proposers derive a new proposal's summary with it, and
67
+ * cosigners and the executor reproduce it, whatever block they have synced to.
113
68
  *
114
69
  * Since protocol 0.17 a multisig summary binds the block its auth args name
115
70
  * (the bound block), not the block the transaction executes against, so it
116
71
  * reproduces at any later tip once the bound block is in the transaction's
117
72
  * partial blockchain. The request declares it through `withBlockNumbers`, and
118
- * foreign accounts, the fee faucet among them, load at the tip. Re-executing at
119
- * the proposal's anchor instead loads them at the bound block, which a node
120
- * prunes about 50 blocks later (issue #462).
73
+ * foreign accounts, the fee faucet among them, load at the tip. Executing at
74
+ * the bound block instead would load them there, which a node prunes about 50
75
+ * blocks later (issue #462).
121
76
  *
122
77
  * The client has to have synced to at least the bound block. When it has not,
123
78
  * this syncs once before executing.
@@ -203,58 +158,55 @@ export async function syncToBoundBlock(
203
158
  }
204
159
 
205
160
  /**
206
- * Executes a transaction at the given `ChainAnchor`'s reference block to
207
- * obtain the summary awaiting authorization.
161
+ * Reads the block a multisig transaction summary binds, the counterpart of the
162
+ * Rust `TransactionSummary::block_number`. Since protocol 0.17 that is the block
163
+ * the request's multisig auth args name, so it is the proposal's bound block.
208
164
  *
209
- * For a summary that binds the reference block, such as a single-signature
210
- * one. A multisig proposal's summary binds its bound block instead and is
211
- * reproduced with {@link executeForSummaryAtTip}: re-executing it at an anchor
212
- * fails once the node prunes the anchor block's account state.
213
- */
214
- export async function executeForSummaryAt(
215
- client: MidenClient,
216
- accountId: string,
217
- txRequest: TransactionRequest,
218
- anchor: ChainAnchor,
219
- ): Promise<TransactionSummary> {
220
- return client.transactions.preview({
221
- operation: 'custom',
222
- account: accountId,
223
- request: txRequest,
224
- anchor,
225
- });
226
- }
227
-
228
- /**
229
- * Serializes a `ChainAnchor` to base64 for the proposal wire payload.
165
+ * The web SDK exposes no accessor for it, so this reads the serialized summary
166
+ * (layout above) and checks the block commitment next to it against
167
+ * `blockCommitment()`, so a layout this client does not know is refused rather
168
+ * than read at the wrong offset.
169
+ *
170
+ * @throws TransactionSummaryLayoutError when the summary is not version 1, is
171
+ * too short to hold the tail, or its tail does not hold its block commitment.
230
172
  */
231
- export function chainAnchorToBase64(anchor: ChainAnchor): string {
232
- return uint8ArrayToBase64(anchor.serialize());
173
+ export function summaryBoundBlockNum(summary: TransactionSummary): number {
174
+ const bytes = summary.serialize();
175
+ const blockCommitment = summary.blockCommitment();
176
+ try {
177
+ return readBoundBlockNum(bytes, blockCommitment.serialize());
178
+ } finally {
179
+ blockCommitment.free();
180
+ }
233
181
  }
234
182
 
235
- /**
236
- * Deserializes a `ChainAnchor` from its base64 wire form. `ChainAnchor`
237
- * deserialization validates the header/chain consistency internally, so a
238
- * decoded anchor only needs its block commitment checked against the signed
239
- * transaction summary before the block it names is taken as the one the
240
- * summary binds.
241
- */
242
- export function chainAnchorFromBase64(anchorBase64: string): ChainAnchor {
243
- return ChainAnchor.deserialize(base64ToUint8Array(anchorBase64));
183
+ function readBoundBlockNum(bytes: Uint8Array, blockCommitment: Uint8Array): number {
184
+ if (bytes.length < 1 + SUMMARY_TAIL_BYTES) {
185
+ throw new TransactionSummaryLayoutError(
186
+ `${bytes.length} bytes is shorter than the version byte and the ${SUMMARY_TAIL_BYTES}-byte tail`,
187
+ );
188
+ }
189
+ if (bytes[0] !== SUPPORTED_SUMMARY_VERSION) {
190
+ throw new TransactionSummaryLayoutError(
191
+ `version ${bytes[0]}, but only version ${SUPPORTED_SUMMARY_VERSION} is supported`,
192
+ );
193
+ }
194
+ const blockNumberOffset = bytes.length - SUMMARY_TAIL_BYTES;
195
+ const commitmentOffset = blockNumberOffset + BLOCK_NUMBER_BYTES;
196
+ const tailCommitment = bytes.subarray(commitmentOffset, commitmentOffset + BLOCK_COMMITMENT_BYTES);
197
+ if (!bytesEqual(tailCommitment, blockCommitment)) {
198
+ throw new TransactionSummaryLayoutError(
199
+ 'the bytes after the block number are not the summary block commitment',
200
+ );
201
+ }
202
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
203
+ blockNumberOffset,
204
+ true,
205
+ );
244
206
  }
245
207
 
246
- /**
247
- * The block a proposal's `chainAnchor` names, which is the block its summary
248
- * binds: a custom producer rebuilds its request at this block. Decodes the
249
- * anchor for the one number and frees it.
250
- */
251
- export function chainAnchorBlockNum(anchorBase64: string): number {
252
- const anchor = chainAnchorFromBase64(anchorBase64);
253
- try {
254
- return anchor.blockNum();
255
- } finally {
256
- anchor.free();
257
- }
208
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
209
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
258
210
  }
259
211
 
260
212
  /**
@@ -11,19 +11,14 @@ export {
11
11
  } from './transaction/consumeNotes.js';
12
12
  export {
13
13
  ChainBehindBoundBlockError,
14
- chainAnchorBlockNum,
15
- chainAnchorFromBase64,
16
- chainAnchorToBase64,
17
- executeForSummary,
18
- executeForSummaryAt,
19
14
  executeForSummaryAtTip,
20
15
  prepareTipExecution,
21
16
  isStaleChainError,
22
17
  requireDeclaredBoundBlock,
23
18
  syncToBoundBlock,
24
19
  summaryApprovalExpirationBlockNum,
20
+ summaryBoundBlockNum,
25
21
  summarySalt,
26
- SummaryAnchorMismatchError,
27
22
  } from './transaction/summary.js';
28
23
  export {
29
24
  buildP2idNoteFromMetadata,
@@ -38,11 +38,17 @@ interface BaseProposalMetadata {
38
38
  saltHex?: string;
39
39
  requiredSignatures?: number;
40
40
  /**
41
- * Base64-serialized Miden `ChainAnchor` at the block the proposal's
42
- * transaction summary binds, the proposer's sync height when it built the
43
- * request. Required, and checked against the summary's block commitment: a
44
- * rebuild binds the block it names. The proposal executes at the chain tip,
45
- * not at the anchor; 0.18.0-rc.1 clients still re-execute at it.
41
+ * The block the proposal's transaction summary binds: the proposer's sync
42
+ * height when it built the request, or the block a custom producer's request
43
+ * names. Not signed: this SDK reads the block from the summary and refuses a
44
+ * proposal whose value disagrees (`BoundBlockMismatchError`), as the Rust SDK
45
+ * does. Every proposal this SDK creates writes it.
46
+ */
47
+ boundBlockNum?: number;
48
+ /**
49
+ * Legacy: the base64 Miden `ChainAnchor` 0.18 clients wrote instead of
50
+ * `boundBlockNum`. This SDK never sets or reads it; the codec passes it
51
+ * through so an old proposal exports and imports unchanged.
46
52
  */
47
53
  chainAnchor?: string;
48
54
  }
@@ -1,17 +1,38 @@
1
1
  import { keccak_256 } from '@noble/hashes/sha3.js';
2
+ import type { SessionGrantFields } from '@openzeppelin/guardian-client';
2
3
  import { bytesToHex, hexToBytes } from './encoding.js';
4
+ import { SESSION_GRANT_SCOPE, formatUtcSeconds } from '../session/grant.js';
3
5
 
4
6
  type TypedField = { name: string; type: string };
7
+
8
+ /**
9
+ * EIP-712 typed data over `string`, `bytes`, `bytes32` and `uint64` fields,
10
+ * as Guardian signs it. Integers are decimal strings, as wallets accept them.
11
+ */
12
+ export interface GuardianTypedData {
13
+ types: Record<string, TypedField[]>;
14
+ primaryType: string;
15
+ domain: { name: string; version: string };
16
+ message: Record<string, string>;
17
+ }
18
+
5
19
  const EIP191_PREFIX = 0x19;
6
20
  const EIP712_VERSION = 0x01;
7
21
  const DOMAIN_VERSION = '1';
22
+ const SESSION_DOMAIN_NAME = 'Guardian Session';
23
+ const UINT64_MAX = 0xffffffffffffffffn;
8
24
 
9
25
  const domainFields: TypedField[] = [
10
26
  { name: 'name', type: 'string' },
11
27
  { name: 'version', type: 'string' },
12
28
  ];
13
29
 
14
- function typedData(name: string, primaryType: string, fieldName: string, value: string) {
30
+ function typedData(
31
+ name: string,
32
+ primaryType: string,
33
+ fieldName: string,
34
+ value: string,
35
+ ): GuardianTypedData {
15
36
  return {
16
37
  types: {
17
38
  EIP712Domain: domainFields,
@@ -39,16 +60,115 @@ export function guardianKeyDiscoveryTypedData(challenge: Uint8Array) {
39
60
  return typedData('Guardian Key Discovery', 'GuardianKeyDiscovery', 'challenge', bytesToHex(challenge));
40
61
  }
41
62
 
42
- export function typedDataDigest(data: ReturnType<typeof typedData>): Uint8Array {
43
- const domainType = new TextEncoder().encode('EIP712Domain(string name,string version)');
44
- const primaryType = new TextEncoder().encode(`${data.primaryType}(bytes32 ${Object.keys(data.message)[0]})`);
63
+ /**
64
+ * Readable session grant: every value Guardian binds is a top-level member,
65
+ * so the wallet displays the signer, the delegated key, the website, the
66
+ * lifetime (with a readable expiry), the scope, the Guardian key and the
67
+ * network instead of a hash.
68
+ */
69
+ export function guardianSessionTypedData(grant: SessionGrantFields): GuardianTypedData {
70
+ return {
71
+ types: {
72
+ EIP712Domain: domainFields,
73
+ GuardianSession: [
74
+ { name: 'signer', type: 'bytes32' },
75
+ { name: 'sessionKey', type: 'bytes' },
76
+ { name: 'origin', type: 'string' },
77
+ { name: 'issuedAt', type: 'uint64' },
78
+ { name: 'expiresAt', type: 'uint64' },
79
+ { name: 'expires', type: 'string' },
80
+ { name: 'scope', type: 'string' },
81
+ { name: 'guardianKey', type: 'bytes32' },
82
+ { name: 'network', type: 'string' },
83
+ ],
84
+ },
85
+ primaryType: 'GuardianSession',
86
+ domain: { name: SESSION_DOMAIN_NAME, version: DOMAIN_VERSION },
87
+ message: {
88
+ signer: grant.signerCommitment,
89
+ sessionKey: grant.sessionPublicKey,
90
+ origin: grant.origin,
91
+ issuedAt: String(grant.issuedAt),
92
+ expiresAt: String(grant.expiresAt),
93
+ expires: formatUtcSeconds(grant.expiresAt),
94
+ scope: SESSION_GRANT_SCOPE,
95
+ guardianKey: grant.guardianCommitment,
96
+ network: grant.network,
97
+ },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Readable revoke-all: the signer whose sessions end and the request
103
+ * timestamp in milliseconds.
104
+ */
105
+ export function guardianSessionRevokeAllTypedData(
106
+ signerCommitment: string,
107
+ timestampMs: number,
108
+ ): GuardianTypedData {
109
+ if (!Number.isInteger(timestampMs) || timestampMs < 0) {
110
+ throw new Error('Revoke-all timestamp must be a non-negative integer');
111
+ }
112
+ return {
113
+ types: {
114
+ EIP712Domain: domainFields,
115
+ GuardianSessionRevokeAll: [
116
+ { name: 'signer', type: 'bytes32' },
117
+ { name: 'timestamp', type: 'uint64' },
118
+ ],
119
+ },
120
+ primaryType: 'GuardianSessionRevokeAll',
121
+ domain: { name: SESSION_DOMAIN_NAME, version: DOMAIN_VERSION },
122
+ message: { signer: signerCommitment, timestamp: String(timestampMs) },
123
+ };
124
+ }
125
+
126
+ function encodeField(field: TypedField, value: string): Uint8Array {
127
+ switch (field.type) {
128
+ case 'string':
129
+ return keccak_256(new TextEncoder().encode(value));
130
+ case 'bytes':
131
+ return keccak_256(hexToBytes(value));
132
+ case 'bytes32': {
133
+ const bytes = hexToBytes(value);
134
+ if (bytes.length !== 32) {
135
+ throw new Error(`EIP-712 field ${field.name} must be 32 bytes`);
136
+ }
137
+ return bytes;
138
+ }
139
+ case 'uint64': {
140
+ let remaining = BigInt(value);
141
+ if (remaining < 0n || remaining > UINT64_MAX) {
142
+ throw new Error(`EIP-712 field ${field.name} must fit in uint64`);
143
+ }
144
+ const bytes = new Uint8Array(32);
145
+ for (let i = 31; i >= 24; i -= 1) {
146
+ bytes[i] = Number(remaining & 0xffn);
147
+ remaining >>= 8n;
148
+ }
149
+ return bytes;
150
+ }
151
+ default:
152
+ throw new Error(`Unsupported EIP-712 field type: ${field.type}`);
153
+ }
154
+ }
155
+
156
+ export function typedDataDigest(data: GuardianTypedData): Uint8Array {
157
+ const encoder = new TextEncoder();
158
+ const fields = data.types[data.primaryType];
159
+ const typeString = `${data.primaryType}(${fields.map((f) => `${f.type} ${f.name}`).join(',')})`;
160
+
45
161
  const domain = new Uint8Array(96);
46
- domain.set(keccak_256(domainType));
47
- domain.set(keccak_256(new TextEncoder().encode(data.domain.name)), 32);
48
- domain.set(keccak_256(new TextEncoder().encode(data.domain.version)), 64);
49
- const struct = new Uint8Array(64);
50
- struct.set(keccak_256(primaryType));
51
- struct.set(hexToBytes(Object.values(data.message)[0]), 32);
162
+ domain.set(keccak_256(encoder.encode('EIP712Domain(string name,string version)')));
163
+ domain.set(keccak_256(encoder.encode(data.domain.name)), 32);
164
+ domain.set(keccak_256(encoder.encode(data.domain.version)), 64);
165
+
166
+ const struct = new Uint8Array(32 * (fields.length + 1));
167
+ struct.set(keccak_256(encoder.encode(typeString)));
168
+ fields.forEach((field, index) => {
169
+ struct.set(encodeField(field, data.message[field.name]), 32 * (index + 1));
170
+ });
171
+
52
172
  const preimage = new Uint8Array(66);
53
173
  preimage.set([EIP191_PREFIX, EIP712_VERSION]);
54
174
  preimage.set(keccak_256(domain), 2);
package/src/utils/felt.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Felt } from '@miden-sdk/miden-sdk';
1
+ import { Felt, FeltArray, Rpo256, type Word } from '@miden-sdk/miden-sdk';
2
2
 
3
3
  /**
4
4
  * Goldilocks field order (`2^64 - 2^32 + 1`), matching `Felt::ORDER` in
@@ -18,3 +18,21 @@ const FELT_ORDER = 18446744069414584321n;
18
18
  export function feltFromU64Reduced(value: bigint): Felt {
19
19
  return new Felt(BigInt.asUintN(64, value) % FELT_ORDER);
20
20
  }
21
+
22
+ /**
23
+ * RPO hash of `bytes` packed as 8-byte little-endian chunks (the last one
24
+ * zero-padded), mirroring `guardian_shared::felt::domain_tag_word`. The
25
+ * convention every Guardian message domain tag uses.
26
+ */
27
+ export function domainTagWord(bytes: Uint8Array): Word {
28
+ const felts: Felt[] = [];
29
+ for (let offset = 0; offset < bytes.length; offset += 8) {
30
+ let value = 0n;
31
+ for (let i = 0; i < 8; i += 1) {
32
+ const byte = offset + i < bytes.length ? bytes[offset + i] : 0;
33
+ value |= BigInt(byte) << BigInt(8 * i);
34
+ }
35
+ felts.push(feltFromU64Reduced(value));
36
+ }
37
+ return Rpo256.hashElements(new FeltArray(felts));
38
+ }