@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.
- package/README.md +99 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -0
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +77 -0
- package/dist/client.test.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts +29 -0
- package/dist/lookupAuth.d.ts.map +1 -0
- package/dist/lookupAuth.js +85 -0
- package/dist/lookupAuth.js.map +1 -0
- package/dist/lookupAuth.test.d.ts +2 -0
- package/dist/lookupAuth.test.d.ts.map +1 -0
- package/dist/lookupAuth.test.js +137 -0
- package/dist/lookupAuth.test.js.map +1 -0
- package/dist/multisig/config.d.ts +8 -0
- package/dist/multisig/config.d.ts.map +1 -0
- package/dist/multisig/config.js +8 -0
- package/dist/multisig/config.js.map +1 -0
- package/dist/multisig/consumeNotesErrors.d.ts +48 -0
- package/dist/multisig/consumeNotesErrors.d.ts.map +1 -0
- package/dist/multisig/consumeNotesErrors.js +58 -0
- package/dist/multisig/consumeNotesErrors.js.map +1 -0
- package/dist/multisig/consumeNotesErrors.test.d.ts +2 -0
- package/dist/multisig/consumeNotesErrors.test.d.ts.map +1 -0
- package/dist/multisig/consumeNotesErrors.test.js +28 -0
- package/dist/multisig/consumeNotesErrors.test.js.map +1 -0
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +63 -4
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +4 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/proposal/metadata.test.d.ts +2 -0
- package/dist/proposal/metadata.test.d.ts.map +1 -0
- package/dist/proposal/metadata.test.js +43 -0
- package/dist/proposal/metadata.test.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -0
- package/dist/signers/ecdsa.d.ts.map +1 -1
- package/dist/signers/ecdsa.js +9 -0
- package/dist/signers/ecdsa.js.map +1 -1
- package/dist/signers/falcon.d.ts +5 -0
- package/dist/signers/falcon.d.ts.map +1 -1
- package/dist/signers/falcon.js +9 -0
- package/dist/signers/falcon.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +5 -0
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +12 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/miden-wallet.test.js +36 -0
- package/dist/signers/miden-wallet.test.js.map +1 -1
- package/dist/signers/para.d.ts +5 -0
- package/dist/signers/para.d.ts.map +1 -1
- package/dist/signers/para.js +9 -0
- package/dist/signers/para.js.map +1 -1
- package/dist/signers/para.test.js +19 -0
- package/dist/signers/para.test.js.map +1 -1
- package/dist/transaction/consumeNotes.d.ts +13 -1
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +30 -13
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/types/proposal.d.ts +10 -0
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +10 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/types/proposal.test.d.ts +2 -0
- package/dist/types/proposal.test.d.ts.map +1 -0
- package/dist/types/proposal.test.js +56 -0
- package/dist/types/proposal.test.js.map +1 -0
- package/dist/utils/encoding.d.ts +8 -0
- package/dist/utils/encoding.d.ts.map +1 -1
- package/dist/utils/encoding.js +8 -0
- package/dist/utils/encoding.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +97 -0
- package/src/client.ts +36 -0
- package/src/index.ts +30 -1
- package/src/lookupAuth.test.ts +184 -0
- package/src/lookupAuth.ts +93 -0
- package/src/multisig/config.ts +7 -0
- package/src/multisig/consumeNotesErrors.test.ts +43 -0
- package/src/multisig/consumeNotesErrors.ts +74 -0
- package/src/multisig.ts +81 -7
- package/src/proposal/metadata.test.ts +47 -0
- package/src/proposal/metadata.ts +4 -0
- package/src/signers/ecdsa.ts +10 -0
- package/src/signers/falcon.ts +10 -0
- package/src/signers/miden-wallet.test.ts +48 -0
- package/src/signers/miden-wallet.ts +13 -0
- package/src/signers/para.test.ts +26 -0
- package/src/signers/para.ts +10 -0
- package/src/transaction/consumeNotes.ts +40 -17
- package/src/types/proposal.test.ts +68 -0
- package/src/types/proposal.ts +18 -0
- 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
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
+
});
|
package/src/proposal/metadata.ts
CHANGED
|
@@ -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) {
|
package/src/signers/ecdsa.ts
CHANGED
|
@@ -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);
|
package/src/signers/falcon.ts
CHANGED
|
@@ -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');
|
package/src/signers/para.test.ts
CHANGED
|
@@ -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 });
|
package/src/signers/para.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
):
|
|
23
|
-
if (
|
|
24
|
-
throw new Error('At least one note
|
|
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
|
|
30
|
-
|
|
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
|
+
});
|