@openzeppelin/miden-multisig-client 0.14.5 → 0.14.8

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 (99) hide show
  1. package/README.md +99 -0
  2. package/dist/client.d.ts +21 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +20 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.test.js +77 -0
  7. package/dist/client.test.js.map +1 -1
  8. package/dist/index.d.ts +6 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/lookupAuth.d.ts +29 -0
  13. package/dist/lookupAuth.d.ts.map +1 -0
  14. package/dist/lookupAuth.js +85 -0
  15. package/dist/lookupAuth.js.map +1 -0
  16. package/dist/lookupAuth.test.d.ts +2 -0
  17. package/dist/lookupAuth.test.d.ts.map +1 -0
  18. package/dist/lookupAuth.test.js +137 -0
  19. package/dist/lookupAuth.test.js.map +1 -0
  20. package/dist/multisig/config.d.ts +8 -0
  21. package/dist/multisig/config.d.ts.map +1 -0
  22. package/dist/multisig/config.js +8 -0
  23. package/dist/multisig/config.js.map +1 -0
  24. package/dist/multisig/consumeNotesErrors.d.ts +48 -0
  25. package/dist/multisig/consumeNotesErrors.d.ts.map +1 -0
  26. package/dist/multisig/consumeNotesErrors.js +58 -0
  27. package/dist/multisig/consumeNotesErrors.js.map +1 -0
  28. package/dist/multisig/consumeNotesErrors.test.d.ts +2 -0
  29. package/dist/multisig/consumeNotesErrors.test.d.ts.map +1 -0
  30. package/dist/multisig/consumeNotesErrors.test.js +28 -0
  31. package/dist/multisig/consumeNotesErrors.test.js.map +1 -0
  32. package/dist/multisig.d.ts.map +1 -1
  33. package/dist/multisig.js +63 -4
  34. package/dist/multisig.js.map +1 -1
  35. package/dist/proposal/metadata.d.ts.map +1 -1
  36. package/dist/proposal/metadata.js +4 -0
  37. package/dist/proposal/metadata.js.map +1 -1
  38. package/dist/proposal/metadata.test.d.ts +2 -0
  39. package/dist/proposal/metadata.test.d.ts.map +1 -0
  40. package/dist/proposal/metadata.test.js +43 -0
  41. package/dist/proposal/metadata.test.js.map +1 -0
  42. package/dist/signers/ecdsa.d.ts +5 -0
  43. package/dist/signers/ecdsa.d.ts.map +1 -1
  44. package/dist/signers/ecdsa.js +9 -0
  45. package/dist/signers/ecdsa.js.map +1 -1
  46. package/dist/signers/falcon.d.ts +5 -0
  47. package/dist/signers/falcon.d.ts.map +1 -1
  48. package/dist/signers/falcon.js +9 -0
  49. package/dist/signers/falcon.js.map +1 -1
  50. package/dist/signers/miden-wallet.d.ts +5 -0
  51. package/dist/signers/miden-wallet.d.ts.map +1 -1
  52. package/dist/signers/miden-wallet.js +12 -0
  53. package/dist/signers/miden-wallet.js.map +1 -1
  54. package/dist/signers/miden-wallet.test.js +36 -0
  55. package/dist/signers/miden-wallet.test.js.map +1 -1
  56. package/dist/signers/para.d.ts +5 -0
  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/signers/para.test.js +19 -0
  61. package/dist/signers/para.test.js.map +1 -1
  62. package/dist/transaction/consumeNotes.d.ts +13 -1
  63. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  64. package/dist/transaction/consumeNotes.js +30 -13
  65. package/dist/transaction/consumeNotes.js.map +1 -1
  66. package/dist/types/proposal.d.ts +10 -0
  67. package/dist/types/proposal.d.ts.map +1 -1
  68. package/dist/types/proposal.js +10 -1
  69. package/dist/types/proposal.js.map +1 -1
  70. package/dist/types/proposal.test.d.ts +2 -0
  71. package/dist/types/proposal.test.d.ts.map +1 -0
  72. package/dist/types/proposal.test.js +56 -0
  73. package/dist/types/proposal.test.js.map +1 -0
  74. package/dist/utils/encoding.d.ts +8 -0
  75. package/dist/utils/encoding.d.ts.map +1 -1
  76. package/dist/utils/encoding.js +8 -0
  77. package/dist/utils/encoding.js.map +1 -1
  78. package/package.json +3 -3
  79. package/src/client.test.ts +97 -0
  80. package/src/client.ts +36 -0
  81. package/src/index.ts +30 -1
  82. package/src/lookupAuth.test.ts +184 -0
  83. package/src/lookupAuth.ts +93 -0
  84. package/src/multisig/config.ts +7 -0
  85. package/src/multisig/consumeNotesErrors.test.ts +43 -0
  86. package/src/multisig/consumeNotesErrors.ts +74 -0
  87. package/src/multisig.ts +81 -7
  88. package/src/proposal/metadata.test.ts +47 -0
  89. package/src/proposal/metadata.ts +4 -0
  90. package/src/signers/ecdsa.ts +10 -0
  91. package/src/signers/falcon.ts +10 -0
  92. package/src/signers/miden-wallet.test.ts +48 -0
  93. package/src/signers/miden-wallet.ts +13 -0
  94. package/src/signers/para.test.ts +26 -0
  95. package/src/signers/para.ts +10 -0
  96. package/src/transaction/consumeNotes.ts +40 -17
  97. package/src/types/proposal.test.ts +68 -0
  98. package/src/types/proposal.ts +18 -0
  99. package/src/utils/encoding.ts +13 -0
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ ConsumeNotesMetadataOversizeError,
4
+ LegacyConsumeNotesNoteMissingError,
5
+ NoteBindingMismatchError,
6
+ UnsupportedMetadataVersionError,
7
+ } from './consumeNotesErrors.js';
8
+
9
+ describe('consume_notes error taxonomy (issue #229)', () => {
10
+ it('pins stable codes identical to the Rust SDK', () => {
11
+ expect(new NoteBindingMismatchError('x').code).toBe('consume_notes_note_binding_mismatch');
12
+ expect(new UnsupportedMetadataVersionError(99).code).toBe(
13
+ 'consume_notes_unsupported_metadata_version',
14
+ );
15
+ expect(new ConsumeNotesMetadataOversizeError(1, 2).code).toBe(
16
+ 'consume_notes_metadata_oversize',
17
+ );
18
+ expect(new LegacyConsumeNotesNoteMissingError('0xabc').code).toBe(
19
+ 'consume_notes_legacy_note_missing',
20
+ );
21
+ });
22
+
23
+ it('preserves structured error fields for programmatic branching', () => {
24
+ const oversize = new ConsumeNotesMetadataOversizeError(262_144, 300_000);
25
+ expect(oversize.limit).toBe(262_144);
26
+ expect(oversize.actual).toBe(300_000);
27
+
28
+ const missing = new LegacyConsumeNotesNoteMissingError('0xnoteid');
29
+ expect(missing.noteId).toBe('0xnoteid');
30
+
31
+ const unsupported = new UnsupportedMetadataVersionError(99);
32
+ expect(unsupported.found).toBe(99);
33
+ const absent = new UnsupportedMetadataVersionError(undefined);
34
+ expect(absent.found).toBeUndefined();
35
+ });
36
+
37
+ it('extends Error so instanceof Error and instanceof <subclass> both work', () => {
38
+ const err = new NoteBindingMismatchError('boom');
39
+ expect(err).toBeInstanceOf(Error);
40
+ expect(err).toBeInstanceOf(NoteBindingMismatchError);
41
+ expect(err.name).toBe('NoteBindingMismatchError');
42
+ });
43
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Stable error identifiers for the `consume_notes` v2 metadata flow
3
+ * (issue #229). These string codes are pinned identical to the Rust
4
+ * SDK's `MultisigError::code()` values per spec FR-021 / FR-022, so
5
+ * cross-SDK tests and operator diagnostics share one taxonomy.
6
+ */
7
+ export type ConsumeNotesErrorCode =
8
+ | 'consume_notes_note_binding_mismatch'
9
+ | 'consume_notes_unsupported_metadata_version'
10
+ | 'consume_notes_metadata_oversize'
11
+ | 'consume_notes_legacy_note_missing';
12
+
13
+ /**
14
+ * v2 metadata's embedded `notes` array did not match its declared
15
+ * `noteIds` — either a length mismatch, or an embedded note whose
16
+ * `note.id()` did not match the corresponding `noteIds[i]`. Spec FR-007.
17
+ */
18
+ export class NoteBindingMismatchError extends Error {
19
+ readonly code: ConsumeNotesErrorCode = 'consume_notes_note_binding_mismatch';
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = 'NoteBindingMismatchError';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * `metadataVersion` is a value the client does not support — either a
28
+ * legacy (v1) proposal on a cut-over client, or an unrecognized future
29
+ * version. Spec FR-009 / FR-019.
30
+ */
31
+ export class UnsupportedMetadataVersionError extends Error {
32
+ readonly code: ConsumeNotesErrorCode = 'consume_notes_unsupported_metadata_version';
33
+ readonly found: number | undefined;
34
+ constructor(found: number | undefined, message?: string) {
35
+ super(
36
+ message ??
37
+ `unsupported consume_notes metadata version: ${found === undefined ? 'absent' : String(found)}`,
38
+ );
39
+ this.name = 'UnsupportedMetadataVersionError';
40
+ this.found = found;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Serialized v2 metadata exceeded the per-proposal cap
46
+ * (`MAX_CONSUME_NOTES_METADATA_BYTES`, 256 KiB). Raised at proposal-
47
+ * creation time before any signature collection begins. Spec FR-011.
48
+ */
49
+ export class ConsumeNotesMetadataOversizeError extends Error {
50
+ readonly code: ConsumeNotesErrorCode = 'consume_notes_metadata_oversize';
51
+ readonly limit: number;
52
+ readonly actual: number;
53
+ constructor(limit: number, actual: number) {
54
+ super(`consume_notes metadata exceeds size limit: limit=${limit} bytes, actual=${actual} bytes`);
55
+ this.name = 'ConsumeNotesMetadataOversizeError';
56
+ this.limit = limit;
57
+ this.actual = actual;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * v1 (legacy) verification path: the cosigner's local Miden store did
63
+ * not contain the referenced note. This is the exact failure issue #229
64
+ * exists to eliminate; on v2 proposals it is not reachable.
65
+ */
66
+ export class LegacyConsumeNotesNoteMissingError extends Error {
67
+ readonly code: ConsumeNotesErrorCode = 'consume_notes_legacy_note_missing';
68
+ readonly noteId: string;
69
+ constructor(noteId: string) {
70
+ super(`consume_notes legacy verification: note not found in local store: ${noteId}`);
71
+ this.name = 'LegacyConsumeNotesNoteMissingError';
72
+ this.noteId = noteId;
73
+ }
74
+ }
package/src/multisig.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  AdviceMap,
29
29
  Endpoint,
30
30
  FeltArray,
31
+ Note,
31
32
  RpcClient,
32
33
  Signature,
33
34
  TransactionSummary,
@@ -41,6 +42,19 @@ import {
41
42
  buildConsumeNotesTransactionRequest,
42
43
  buildP2idTransactionRequest,
43
44
  } from './transaction.js';
45
+ import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
46
+ import {
47
+ CONSUME_NOTES_METADATA_VERSION_V2,
48
+ MAX_CONSUME_NOTES_METADATA_BYTES,
49
+ } from './types/proposal.js';
50
+ import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
51
+ import {
52
+ ConsumeNotesMetadataOversizeError,
53
+ LegacyConsumeNotesNoteMissingError,
54
+ NoteBindingMismatchError,
55
+ UnsupportedMetadataVersionError,
56
+ } from './multisig/consumeNotesErrors.js';
57
+ import { noteFromBase64, noteToBase64 } from './utils/encoding.js';
44
58
  import {
45
59
  base64ToUint8Array,
46
60
  uint8ArrayToBase64,
@@ -728,7 +742,19 @@ export class Multisig {
728
742
  throw new Error('At least one note ID is required');
729
743
  }
730
744
 
731
- const { request, salt } = await buildConsumeNotesTransactionRequest(webClient, noteIds);
745
+ // Fetch notes locally (proposer has them per FR-012); embed for v2 verification.
746
+ const rawClient = await getRawMidenClient(webClient);
747
+ const fetchedNotes: Note[] = [];
748
+ for (const noteIdHex of noteIds) {
749
+ const inputNoteRecord = await rawClient.getInputNote(noteIdHex);
750
+ if (!inputNoteRecord) {
751
+ throw new LegacyConsumeNotesNoteMissingError(noteIdHex);
752
+ }
753
+ fetchedNotes.push(inputNoteRecord.toNote());
754
+ }
755
+ const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
756
+
757
+ const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
732
758
 
733
759
  const summary = await executeForSummary(webClient, this._accountId, request);
734
760
  const summaryBase64 = uint8ArrayToBase64(summary.serialize());
@@ -737,11 +763,23 @@ export class Multisig {
737
763
  const metadata: ProposalMetadata = {
738
764
  proposalType: 'consume_notes',
739
765
  noteIds,
766
+ metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
767
+ notes: embeddedNotes,
740
768
  saltHex: salt.toHex(),
741
769
  requiredSignatures: this.getEffectiveThreshold('consume_notes'),
742
770
  description: `Consume ${noteIds.length} note(s)`,
743
771
  };
744
772
 
773
+ // FR-011: enforce metadata size cap on the wire-encoded form (what GUARDIAN
774
+ // actually persists), matching the Rust side which measures
775
+ // `ProposalMetadataPayload`. Sizing the local in-memory `metadata` would
776
+ // miss codec divergence.
777
+ const encoded = ProposalMetadataCodec.toGuardian(metadata);
778
+ const metadataSize = new TextEncoder().encode(JSON.stringify(encoded)).length;
779
+ if (metadataSize > MAX_CONSUME_NOTES_METADATA_BYTES) {
780
+ throw new ConsumeNotesMetadataOversizeError(MAX_CONSUME_NOTES_METADATA_BYTES, metadataSize);
781
+ }
782
+
745
783
  return this.createProposal(proposalNonce, summaryBase64, metadata);
746
784
  }
747
785
 
@@ -1319,12 +1357,48 @@ export class Multisig {
1319
1357
  return request;
1320
1358
  }
1321
1359
  case 'consume_notes': {
1322
- const { request } = await buildConsumeNotesTransactionRequest(
1323
- webClient,
1324
- metadata.noteIds,
1325
- { salt, signatureAdviceMap }
1326
- );
1327
- return request;
1360
+ // v1/v2 dispatch for issue #229 / FR-009.
1361
+ const version = metadata.metadataVersion;
1362
+ if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
1363
+ const embedded = metadata.notes ?? [];
1364
+ if (embedded.length !== metadata.noteIds.length) {
1365
+ throw new NoteBindingMismatchError(
1366
+ `consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${metadata.noteIds.length}`,
1367
+ );
1368
+ }
1369
+ const decoded: Note[] = [];
1370
+ for (let i = 0; i < embedded.length; i++) {
1371
+ const note = noteFromBase64(embedded[i], Note);
1372
+ // Normalize both sides; matches the file's other hex comparisons.
1373
+ const embeddedId = normalizeHexWord(note.id().toString());
1374
+ const declaredId = normalizeHexWord(metadata.noteIds[i]);
1375
+ if (embeddedId !== declaredId) {
1376
+ throw new NoteBindingMismatchError(
1377
+ `consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
1378
+ );
1379
+ }
1380
+ decoded.push(note);
1381
+ }
1382
+ const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
1383
+ salt,
1384
+ signatureAdviceMap,
1385
+ });
1386
+ return request;
1387
+ }
1388
+ if (version === undefined || version === 1) {
1389
+ if (!LEGACY_CONSUME_NOTES_ENABLED) {
1390
+ // Preserve explicit `1` vs absent so the error tells the
1391
+ // operator which legacy shape was rejected.
1392
+ throw new UnsupportedMetadataVersionError(version);
1393
+ }
1394
+ const { request } = await buildConsumeNotesTransactionRequest(
1395
+ webClient,
1396
+ metadata.noteIds,
1397
+ { salt, signatureAdviceMap },
1398
+ );
1399
+ return request;
1400
+ }
1401
+ throw new UnsupportedMetadataVersionError(version);
1328
1402
  }
1329
1403
  case 'p2id': {
1330
1404
  const { request } = buildP2idTransactionRequest(
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ProposalMetadata as GuardianProposalMetadata } from '@openzeppelin/guardian-client';
3
+ import { ProposalMetadataCodec } from './metadata.js';
4
+ import type { ConsumeNotesProposalMetadata } from '../types/proposal.js';
5
+
6
+ describe('ProposalMetadataCodec consume_notes v2 round-trip (issue #229)', () => {
7
+ it('toGuardian threads metadataVersion and notes to the wire', () => {
8
+ const md: ConsumeNotesProposalMetadata = {
9
+ proposalType: 'consume_notes',
10
+ description: 'consume one note',
11
+ noteIds: ['0xabc'],
12
+ metadataVersion: 2,
13
+ notes: ['YmFzZTY0Tm90ZQ=='],
14
+ };
15
+ const wire = ProposalMetadataCodec.toGuardian(md);
16
+ expect(wire.noteIds).toEqual(['0xabc']);
17
+ expect(wire.consumeNotesMetadataVersion).toBe(2);
18
+ expect(wire.consumeNotesNotes).toEqual(['YmFzZTY0Tm90ZQ==']);
19
+ });
20
+
21
+ it('fromGuardian reconstructs the v2 fields', () => {
22
+ const wire: GuardianProposalMetadata = {
23
+ proposalType: 'consume_notes',
24
+ noteIds: ['0xabc'],
25
+ consumeNotesMetadataVersion: 2,
26
+ consumeNotesNotes: ['YmFzZTY0Tm90ZQ=='],
27
+ };
28
+ const md = ProposalMetadataCodec.fromGuardian(wire) as ConsumeNotesProposalMetadata;
29
+ expect(md.proposalType).toBe('consume_notes');
30
+ expect(md.metadataVersion).toBe(2);
31
+ expect(md.notes).toEqual(['YmFzZTY0Tm90ZQ==']);
32
+ });
33
+
34
+ it('round-trips a v1 (legacy) proposal without spurious v2 fields', () => {
35
+ const md: ConsumeNotesProposalMetadata = {
36
+ proposalType: 'consume_notes',
37
+ description: 'legacy',
38
+ noteIds: ['0xabc'],
39
+ };
40
+ const wire = ProposalMetadataCodec.toGuardian(md);
41
+ expect(wire.consumeNotesMetadataVersion).toBeUndefined();
42
+ expect(wire.consumeNotesNotes).toBeUndefined();
43
+ const back = ProposalMetadataCodec.fromGuardian(wire) as ConsumeNotesProposalMetadata;
44
+ expect(back.metadataVersion).toBeUndefined();
45
+ expect(back.notes).toBeUndefined();
46
+ });
47
+ });
@@ -16,6 +16,8 @@ export class ProposalMetadataCodec {
16
16
  return {
17
17
  ...base,
18
18
  noteIds: metadata.noteIds,
19
+ consumeNotesMetadataVersion: metadata.metadataVersion,
20
+ consumeNotesNotes: metadata.notes,
19
21
  };
20
22
  case 'p2id':
21
23
  return {
@@ -82,6 +84,8 @@ export class ProposalMetadataCodec {
82
84
  ...base,
83
85
  proposalType: 'consume_notes',
84
86
  noteIds: guardian.noteIds,
87
+ metadataVersion: guardian.consumeNotesMetadataVersion as 1 | 2 | undefined,
88
+ notes: guardian.consumeNotesNotes,
85
89
  };
86
90
  case 'switch_guardian':
87
91
  if (!guardian.newGuardianPubkey || !guardian.newGuardianEndpoint) {
@@ -3,6 +3,7 @@ import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sd
3
3
  import type { Signer, SignatureScheme } from '../types.js';
4
4
  import { bytesToHex, normalizeHexWord } from '../utils/encoding.js';
5
5
  import { AuthDigest } from '../utils/digest.js';
6
+ import { lookupAuthDigest } from '../lookupAuth.js';
6
7
 
7
8
  export class EcdsaSigner implements Signer {
8
9
  readonly commitment: string;
@@ -39,6 +40,15 @@ export class EcdsaSigner implements Signer {
39
40
  return this.signWord(word);
40
41
  }
41
42
 
43
+ /**
44
+ * Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
45
+ * Account-less; used directly by `recoverByKey`.
46
+ */
47
+ async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
48
+ const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
49
+ return this.signWord(digest);
50
+ }
51
+
42
52
  async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
43
53
  const targetAccountId = AccountId.fromHex(accountId);
44
54
  const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
@@ -3,6 +3,7 @@ import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sd
3
3
  import type { Signer, SignatureScheme } from '../types.js';
4
4
  import { bytesToHex } from '../utils/encoding.js';
5
5
  import { AuthDigest } from '../utils/digest.js';
6
+ import { lookupAuthDigest } from '../lookupAuth.js';
6
7
 
7
8
  export class FalconSigner implements Signer {
8
9
  readonly commitment: string;
@@ -40,6 +41,15 @@ export class FalconSigner implements Signer {
40
41
  return this.signWord(word);
41
42
  }
42
43
 
44
+ /**
45
+ * Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
46
+ * Account-less; used directly by `recoverByKey`.
47
+ */
48
+ async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
49
+ const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
50
+ return this.signWord(digest);
51
+ }
52
+
43
53
  async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
44
54
  const targetAccountId = AccountId.fromHex(accountId);
45
55
  const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
@@ -153,6 +153,54 @@ describe('MidenWalletSigner', () => {
153
153
  });
154
154
  });
155
155
 
156
+ describe('signLookupMessage', () => {
157
+ it('delegates to localAuthSigner when present', async () => {
158
+ const localSigner: Signer = {
159
+ commitment: '0xlocal',
160
+ publicKey: '0xlocalpubkey',
161
+ scheme: 'ecdsa',
162
+ signAccountIdWithTimestamp: vi.fn(),
163
+ signCommitment: vi.fn(),
164
+ signLookupMessage: vi.fn().mockResolvedValue('0xlocallookupsig'),
165
+ };
166
+ const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', localSigner);
167
+
168
+ const result = await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
169
+
170
+ expect(result).toBe('0xlocallookupsig');
171
+ expect(localSigner.signLookupMessage).toHaveBeenCalledWith(
172
+ '0x' + 'cc'.repeat(32),
173
+ 1700000000,
174
+ );
175
+ expect(mockWallet.signBytes).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('uses wallet signing over the LookupAuthMessage digest when no localAuthSigner', async () => {
179
+ const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'falcon');
180
+
181
+ const result = await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
182
+
183
+ expect(mockWallet.signBytes).toHaveBeenCalledTimes(1);
184
+ expect(mockWallet.signBytes).toHaveBeenCalledWith(expect.any(Uint8Array), 'word');
185
+ expect(result).toMatch(/^0x/);
186
+ });
187
+
188
+ it('falls back to wallet signing when localAuthSigner does not implement signLookupMessage', async () => {
189
+ const localSigner: Signer = {
190
+ commitment: '0xlocal',
191
+ publicKey: '0xlocalpubkey',
192
+ scheme: 'ecdsa',
193
+ signAccountIdWithTimestamp: vi.fn(),
194
+ signCommitment: vi.fn(),
195
+ };
196
+ const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', localSigner);
197
+
198
+ await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
199
+
200
+ expect(mockWallet.signBytes).toHaveBeenCalledTimes(1);
201
+ });
202
+ });
203
+
156
204
  describe('signature byte stripping', () => {
157
205
  it('should strip the first byte from wallet signature response', async () => {
158
206
  (mockWallet.signBytes as ReturnType<typeof vi.fn>).mockResolvedValue(
@@ -2,6 +2,7 @@ import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
2
2
  import type { Signer, SignatureScheme } from '../types.js';
3
3
  import { AuthDigest } from '../utils/digest.js';
4
4
  import { bytesToHex } from '../utils/encoding.js';
5
+ import { lookupAuthDigest } from '../lookupAuth.js';
5
6
  import { wordToBytes } from '../utils/word.js';
6
7
 
7
8
  export interface WalletSigningContext {
@@ -57,6 +58,18 @@ export class MidenWalletSigner implements Signer {
57
58
  return this.signWord(word);
58
59
  }
59
60
 
61
+ /**
62
+ * Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
63
+ * Account-less; used directly by `recoverByKey`.
64
+ */
65
+ async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
66
+ if (this.localAuthSigner?.signLookupMessage) {
67
+ return this.localAuthSigner.signLookupMessage(keyCommitmentHex, timestampMs);
68
+ }
69
+ const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
70
+ return this.signWord(digest);
71
+ }
72
+
60
73
  private async signWord(word: { toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
61
74
  const bytes = wordToBytes(word);
62
75
  const signatureBytes = await this.wallet.signBytes(bytes, 'word');
@@ -148,6 +148,32 @@ describe('ParaSigner', () => {
148
148
  });
149
149
  });
150
150
 
151
+ describe('signLookupMessage', () => {
152
+ it('hashes the LookupAuthMessage digest word bytes before signing', async () => {
153
+ const signer = new ParaSigner(mockPara, 'wallet-1', '0xcommitment', validCompressedKey);
154
+ await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
155
+
156
+ const expectedDigestBytes = keccak_256(
157
+ wordToBytes({
158
+ toFelts: () => [
159
+ { asInt: () => 10n },
160
+ { asInt: () => 20n },
161
+ { asInt: () => 30n },
162
+ { asInt: () => 40n },
163
+ ],
164
+ }),
165
+ );
166
+ const expectedBase64 = uint8ArrayToBase64(expectedDigestBytes);
167
+
168
+ expect(mockPara.signMessage).toHaveBeenCalledWith(
169
+ expect.objectContaining({
170
+ walletId: 'wallet-1',
171
+ messageBase64: expectedBase64,
172
+ }),
173
+ );
174
+ });
175
+ });
176
+
151
177
  describe('signing denial', () => {
152
178
  it('should throw when para returns no signature', async () => {
153
179
  (mockPara.signMessage as ReturnType<typeof vi.fn>).mockResolvedValue({ signature: undefined });
@@ -3,6 +3,7 @@ import type { Signer, SignatureScheme } from '../types.js';
3
3
  import { AuthDigest } from '../utils/digest.js';
4
4
  import { EcdsaFormat } from '../utils/ecdsa.js';
5
5
  import { hexToBytes, uint8ArrayToBase64 } from '../utils/encoding.js';
6
+ import { lookupAuthDigest } from '../lookupAuth.js';
6
7
  import { wordToBytes } from '../utils/word.js';
7
8
 
8
9
  export interface ParaSigningContext {
@@ -59,6 +60,15 @@ export class ParaSigner implements Signer {
59
60
  return this.signWord(word);
60
61
  }
61
62
 
63
+ /**
64
+ * Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
65
+ * Account-less; used directly by `recoverByKey`.
66
+ */
67
+ async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
68
+ const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
69
+ return this.signWord(digest);
70
+ }
71
+
62
72
  private async signWord(word: { toHex: () => string; toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
63
73
  const messageBase64 = uint8ArrayToBase64(
64
74
  hexToBytes(EcdsaFormat.keccakDigestHex(wordToBytes(word))),
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  MidenClient,
3
+ Note,
3
4
  TransactionRequest,
4
5
  WasmWebClient,
5
6
  Word,
@@ -10,34 +11,30 @@ import {
10
11
  TransactionRequestBuilder,
11
12
  Word as WordType,
12
13
  } from '@miden-sdk/miden-sdk';
14
+ import { LegacyConsumeNotesNoteMissingError } from '../multisig/consumeNotesErrors.js';
13
15
  import { getRawMidenClient } from '../raw-client.js';
14
- import { randomWord } from '../utils/random.js';
15
16
  import { normalizeHexWord } from '../utils/encoding.js';
17
+ import { randomWord } from '../utils/random.js';
16
18
  import type { SignatureOptions } from './options.js';
17
19
 
18
- export async function buildConsumeNotesTransactionRequest(
19
- client: MidenClient | WasmWebClient,
20
- noteIds: string[],
20
+ /**
21
+ * Build a consume-notes request from loaded `Note` objects (no local-store
22
+ * read). v2 verification path for issue #229.
23
+ */
24
+ export function buildConsumeNotesTransactionRequestFromNotes(
25
+ notes: Note[],
21
26
  options: SignatureOptions = {},
22
- ): Promise<{ request: TransactionRequest; salt: Word }> {
23
- if (noteIds.length === 0) {
24
- throw new Error('At least one note ID is required');
27
+ ): { request: TransactionRequest; salt: Word } {
28
+ if (notes.length === 0) {
29
+ throw new Error('At least one note is required');
25
30
  }
26
31
 
27
- const rawClient = await getRawMidenClient(client, options.midenRpcEndpoint);
28
32
  const noteAndArgsArray = new NoteAndArgsArray();
29
- for (const noteIdHex of noteIds) {
30
- const inputNoteRecord = await rawClient.getInputNote(noteIdHex);
31
- if (!inputNoteRecord) {
32
- throw new Error(`Note not found in local store: ${noteIdHex}`);
33
- }
34
- const note = inputNoteRecord.toNote();
35
- const noteAndArgs = new NoteAndArgs(note, null);
36
- noteAndArgsArray.push(noteAndArgs);
33
+ for (const note of notes) {
34
+ noteAndArgsArray.push(new NoteAndArgs(note, null));
37
35
  }
38
36
 
39
37
  const authSaltHex = options.salt ? options.salt.toHex() : randomWord().toHex();
40
-
41
38
  const authSaltForBuilder = WordType.fromHex(normalizeHexWord(authSaltHex));
42
39
 
43
40
  let txBuilder = new TransactionRequestBuilder();
@@ -55,3 +52,29 @@ export async function buildConsumeNotesTransactionRequest(
55
52
  salt: authSaltForReturn,
56
53
  };
57
54
  }
55
+
56
+ /**
57
+ * Legacy/creation adapter: fetches notes from the local store and delegates
58
+ * to the from-notes variant. v2 verification MUST NOT call this.
59
+ */
60
+ export async function buildConsumeNotesTransactionRequest(
61
+ client: MidenClient | WasmWebClient,
62
+ noteIds: string[],
63
+ options: SignatureOptions = {},
64
+ ): Promise<{ request: TransactionRequest; salt: Word }> {
65
+ if (noteIds.length === 0) {
66
+ throw new Error('At least one note ID is required');
67
+ }
68
+
69
+ const rawClient = await getRawMidenClient(client, options.midenRpcEndpoint);
70
+ const notes: Note[] = [];
71
+ for (const noteIdHex of noteIds) {
72
+ const inputNoteRecord = await rawClient.getInputNote(noteIdHex);
73
+ if (!inputNoteRecord) {
74
+ throw new LegacyConsumeNotesNoteMissingError(noteIdHex);
75
+ }
76
+ notes.push(inputNoteRecord.toNote());
77
+ }
78
+
79
+ return buildConsumeNotesTransactionRequestFromNotes(notes, options);
80
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CONSUME_NOTES_METADATA_VERSION_V2,
4
+ type ConsumeNotesProposalMetadata,
5
+ MAX_CONSUME_NOTES_METADATA_BYTES,
6
+ isConsumeNotesV1,
7
+ isConsumeNotesV2,
8
+ } from './proposal.js';
9
+
10
+ describe('consume_notes metadata v1/v2 discriminator (issue #229)', () => {
11
+ it('v1 metadata omits metadataVersion and notes on the wire', () => {
12
+ const md: ConsumeNotesProposalMetadata = {
13
+ proposalType: 'consume_notes',
14
+ description: '',
15
+ noteIds: ['0xabc', '0xdef'],
16
+ };
17
+
18
+ const json = JSON.parse(JSON.stringify(md));
19
+ expect(json.metadataVersion).toBeUndefined();
20
+ expect(json.notes).toBeUndefined();
21
+ expect(isConsumeNotesV1(md)).toBe(true);
22
+ expect(isConsumeNotesV2(md)).toBe(false);
23
+ });
24
+
25
+ it('v2 metadata round-trips with discriminator and embedded notes', () => {
26
+ const md: ConsumeNotesProposalMetadata = {
27
+ proposalType: 'consume_notes',
28
+ description: '',
29
+ noteIds: ['0xabc'],
30
+ metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
31
+ notes: ['YmFzZTY0Tm90ZQ=='],
32
+ };
33
+
34
+ const json = JSON.stringify(md);
35
+ const parsed = JSON.parse(json) as ConsumeNotesProposalMetadata;
36
+ expect(parsed.metadataVersion).toBe(2);
37
+ expect(parsed.notes).toEqual(['YmFzZTY0Tm90ZQ==']);
38
+ expect(isConsumeNotesV2(parsed)).toBe(true);
39
+ expect(isConsumeNotesV1(parsed)).toBe(false);
40
+ });
41
+
42
+ it('explicit metadataVersion === 1 is treated as v1', () => {
43
+ const md = {
44
+ proposalType: 'consume_notes',
45
+ description: '',
46
+ noteIds: ['0xabc'],
47
+ metadataVersion: 1 as unknown as 2, // legacy explicit-v1 case
48
+ } as ConsumeNotesProposalMetadata;
49
+ expect(isConsumeNotesV1(md)).toBe(true);
50
+ expect(isConsumeNotesV2(md)).toBe(false);
51
+ });
52
+
53
+ it('unknown future version is neither v1 nor v2; dispatch handles rejection', () => {
54
+ const md = {
55
+ proposalType: 'consume_notes',
56
+ description: '',
57
+ noteIds: ['0xabc'],
58
+ metadataVersion: 99 as unknown as 2,
59
+ } as ConsumeNotesProposalMetadata;
60
+ expect(isConsumeNotesV1(md)).toBe(false);
61
+ expect(isConsumeNotesV2(md)).toBe(false);
62
+ });
63
+
64
+ it('pins MAX_CONSUME_NOTES_METADATA_BYTES at 256 KiB', () => {
65
+ expect(MAX_CONSUME_NOTES_METADATA_BYTES).toBe(256 * 1024);
66
+ expect(MAX_CONSUME_NOTES_METADATA_BYTES).toBe(262_144);
67
+ });
68
+ });