@openzeppelin/miden-multisig-client 0.14.6 → 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 (55) hide show
  1. package/README.md +67 -0
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/multisig/config.d.ts +8 -0
  7. package/dist/multisig/config.d.ts.map +1 -0
  8. package/dist/multisig/config.js +8 -0
  9. package/dist/multisig/config.js.map +1 -0
  10. package/dist/multisig/consumeNotesErrors.d.ts +48 -0
  11. package/dist/multisig/consumeNotesErrors.d.ts.map +1 -0
  12. package/dist/multisig/consumeNotesErrors.js +58 -0
  13. package/dist/multisig/consumeNotesErrors.js.map +1 -0
  14. package/dist/multisig/consumeNotesErrors.test.d.ts +2 -0
  15. package/dist/multisig/consumeNotesErrors.test.d.ts.map +1 -0
  16. package/dist/multisig/consumeNotesErrors.test.js +28 -0
  17. package/dist/multisig/consumeNotesErrors.test.js.map +1 -0
  18. package/dist/multisig.d.ts.map +1 -1
  19. package/dist/multisig.js +63 -4
  20. package/dist/multisig.js.map +1 -1
  21. package/dist/proposal/metadata.d.ts.map +1 -1
  22. package/dist/proposal/metadata.js +4 -0
  23. package/dist/proposal/metadata.js.map +1 -1
  24. package/dist/proposal/metadata.test.d.ts +2 -0
  25. package/dist/proposal/metadata.test.d.ts.map +1 -0
  26. package/dist/proposal/metadata.test.js +43 -0
  27. package/dist/proposal/metadata.test.js.map +1 -0
  28. package/dist/transaction/consumeNotes.d.ts +13 -1
  29. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  30. package/dist/transaction/consumeNotes.js +30 -13
  31. package/dist/transaction/consumeNotes.js.map +1 -1
  32. package/dist/types/proposal.d.ts +10 -0
  33. package/dist/types/proposal.d.ts.map +1 -1
  34. package/dist/types/proposal.js +10 -1
  35. package/dist/types/proposal.js.map +1 -1
  36. package/dist/types/proposal.test.d.ts +2 -0
  37. package/dist/types/proposal.test.d.ts.map +1 -0
  38. package/dist/types/proposal.test.js +56 -0
  39. package/dist/types/proposal.test.js.map +1 -0
  40. package/dist/utils/encoding.d.ts +8 -0
  41. package/dist/utils/encoding.d.ts.map +1 -1
  42. package/dist/utils/encoding.js +8 -0
  43. package/dist/utils/encoding.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/index.ts +24 -0
  46. package/src/multisig/config.ts +7 -0
  47. package/src/multisig/consumeNotesErrors.test.ts +43 -0
  48. package/src/multisig/consumeNotesErrors.ts +74 -0
  49. package/src/multisig.ts +81 -7
  50. package/src/proposal/metadata.test.ts +47 -0
  51. package/src/proposal/metadata.ts +4 -0
  52. package/src/transaction/consumeNotes.ts +40 -17
  53. package/src/types/proposal.test.ts +68 -0
  54. package/src/types/proposal.ts +18 -0
  55. package/src/utils/encoding.ts +13 -0
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) {
@@ -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
+ });
@@ -49,9 +49,27 @@ export interface UpdateProcedureThresholdProposalMetadata extends BaseProposalMe
49
49
  targetThreshold: number;
50
50
  }
51
51
 
52
+ /** `consume_notes` metadata version. Absence on the wire => v1 (issue #229). */
53
+ export const CONSUME_NOTES_METADATA_VERSION_V2 = 2 as const;
54
+
55
+ /** Max serialized v2 metadata, enforced at creation (FR-011). */
56
+ export const MAX_CONSUME_NOTES_METADATA_BYTES = 256 * 1024;
57
+
52
58
  export interface ConsumeNotesProposalMetadata extends BaseProposalMetadata {
53
59
  proposalType: 'consume_notes';
54
60
  noteIds: string[];
61
+ /** Absent or `1` => v1 (legacy), `2` => v2 (issue #229). */
62
+ metadataVersion?: 1 | 2;
63
+ /** v2: base64-encoded `note.serialize()` output, index-aligned with `noteIds`. */
64
+ notes?: string[];
65
+ }
66
+
67
+ export function isConsumeNotesV2(md: ConsumeNotesProposalMetadata): boolean {
68
+ return md.metadataVersion === CONSUME_NOTES_METADATA_VERSION_V2;
69
+ }
70
+
71
+ export function isConsumeNotesV1(md: ConsumeNotesProposalMetadata): boolean {
72
+ return md.metadataVersion === undefined || md.metadataVersion === 1;
55
73
  }
56
74
 
57
75
  export interface P2IdProposalMetadata extends BaseProposalMetadata {
@@ -40,6 +40,19 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
40
40
  throw new Error('No base64 encoder available in this environment');
41
41
  }
42
42
 
43
+ /** Serialize a Miden `Note` to base64 (v2 `consume_notes` metadata, issue #229). */
44
+ export function noteToBase64(note: { serialize(): Uint8Array }): string {
45
+ return uint8ArrayToBase64(note.serialize());
46
+ }
47
+
48
+ /** Decode a base64 note. `noteCtor` is `Note` from `@miden-sdk/miden-sdk`. */
49
+ export function noteFromBase64<TNote>(
50
+ base64: string,
51
+ noteCtor: { deserialize(bytes: Uint8Array): TNote },
52
+ ): TNote {
53
+ return noteCtor.deserialize(base64ToUint8Array(base64));
54
+ }
55
+
43
56
  export function base64ToUint8Array(base64: string): Uint8Array {
44
57
  const binary =
45
58
  typeof atob === 'function'