@openzeppelin/miden-multisig-client 0.15.0 → 0.16.0

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 (131) hide show
  1. package/README.md +25 -1
  2. package/dist/account/builder.d.ts +2 -1
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +1 -0
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/builder.test.js +2 -2
  7. package/dist/account/builder.test.js.map +1 -1
  8. package/dist/client.d.ts +9 -5
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +5 -5
  11. package/dist/client.js.map +1 -1
  12. package/dist/client.test.js +46 -15
  13. package/dist/client.test.js.map +1 -1
  14. package/dist/connectivity.d.ts +38 -0
  15. package/dist/connectivity.d.ts.map +1 -0
  16. package/dist/connectivity.js +97 -0
  17. package/dist/connectivity.js.map +1 -0
  18. package/dist/connectivity.test.d.ts +2 -0
  19. package/dist/connectivity.test.d.ts.map +1 -0
  20. package/dist/connectivity.test.js +61 -0
  21. package/dist/connectivity.test.js.map +1 -0
  22. package/dist/index.d.ts +9 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/multisig.d.ts +68 -7
  27. package/dist/multisig.d.ts.map +1 -1
  28. package/dist/multisig.js +90 -18
  29. package/dist/multisig.js.map +1 -1
  30. package/dist/multisig.test.js +214 -79
  31. package/dist/multisig.test.js.map +1 -1
  32. package/dist/proposal/metadata.d.ts.map +1 -1
  33. package/dist/proposal/metadata.js +12 -0
  34. package/dist/proposal/metadata.js.map +1 -1
  35. package/dist/proposal/metadata.test.js +49 -0
  36. package/dist/proposal/metadata.test.js.map +1 -1
  37. package/dist/raw-client.d.ts +2 -2
  38. package/dist/raw-client.d.ts.map +1 -1
  39. package/dist/raw-client.js +14 -4
  40. package/dist/raw-client.js.map +1 -1
  41. package/dist/raw-client.test.js +25 -3
  42. package/dist/raw-client.test.js.map +1 -1
  43. package/dist/signers/miden-wallet.d.ts +44 -1
  44. package/dist/signers/miden-wallet.d.ts.map +1 -1
  45. package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts +2 -0
  46. package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts.map +1 -0
  47. package/dist/signers/miden-wallet.ecdsa-recovery.test.js +148 -0
  48. package/dist/signers/miden-wallet.ecdsa-recovery.test.js.map +1 -0
  49. package/dist/signers/miden-wallet.js +97 -6
  50. package/dist/signers/miden-wallet.js.map +1 -1
  51. package/dist/signers/miden-wallet.test.js +8 -13
  52. package/dist/signers/miden-wallet.test.js.map +1 -1
  53. package/dist/transaction/consumeNotes.d.ts +6 -2
  54. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  55. package/dist/transaction/consumeNotes.js +0 -4
  56. package/dist/transaction/consumeNotes.js.map +1 -1
  57. package/dist/transaction/options.d.ts +3 -0
  58. package/dist/transaction/options.d.ts.map +1 -1
  59. package/dist/transaction/p2id.d.ts +20 -2
  60. package/dist/transaction/p2id.d.ts.map +1 -1
  61. package/dist/transaction/p2id.js +46 -3
  62. package/dist/transaction/p2id.js.map +1 -1
  63. package/dist/transaction/p2id.test.js +66 -6
  64. package/dist/transaction/p2id.test.js.map +1 -1
  65. package/dist/transaction/summary.d.ts +2 -1
  66. package/dist/transaction/summary.d.ts.map +1 -1
  67. package/dist/transaction/summary.js.map +1 -1
  68. package/dist/transaction/updateGuardian.d.ts +6 -2
  69. package/dist/transaction/updateGuardian.d.ts.map +1 -1
  70. package/dist/transaction/updateGuardian.js.map +1 -1
  71. package/dist/transaction/updateProcedureThreshold.d.ts +7 -2
  72. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
  73. package/dist/transaction/updateProcedureThreshold.js.map +1 -1
  74. package/dist/transaction/updateSigners.d.ts +7 -2
  75. package/dist/transaction/updateSigners.d.ts.map +1 -1
  76. package/dist/transaction/updateSigners.js.map +1 -1
  77. package/dist/transaction.d.ts +1 -1
  78. package/dist/transaction.d.ts.map +1 -1
  79. package/dist/transaction.js +1 -1
  80. package/dist/transaction.js.map +1 -1
  81. package/dist/types/proposal.d.ts +5 -0
  82. package/dist/types/proposal.d.ts.map +1 -1
  83. package/dist/types/proposal.js +3 -0
  84. package/dist/types/proposal.js.map +1 -1
  85. package/dist/utils/ecdsa.d.ts +17 -0
  86. package/dist/utils/ecdsa.d.ts.map +1 -1
  87. package/dist/utils/ecdsa.js +44 -1
  88. package/dist/utils/ecdsa.js.map +1 -1
  89. package/dist/utils/ecdsa.test.js +46 -0
  90. package/dist/utils/ecdsa.test.js.map +1 -1
  91. package/package.json +12 -3
  92. package/src/account/builder.test.ts +19 -11
  93. package/src/account/builder.ts +2 -1
  94. package/src/client.test.ts +67 -15
  95. package/src/client.ts +18 -10
  96. package/src/connectivity.test.ts +67 -0
  97. package/src/connectivity.ts +111 -0
  98. package/src/index.ts +20 -1
  99. package/src/multisig.test.ts +267 -80
  100. package/src/multisig.ts +109 -20
  101. package/src/proposal/metadata.test.ts +60 -0
  102. package/src/proposal/metadata.ts +14 -0
  103. package/src/raw-client.test.ts +41 -3
  104. package/src/raw-client.ts +15 -5
  105. package/src/signers/miden-wallet.ecdsa-recovery.test.ts +201 -0
  106. package/src/signers/miden-wallet.test.ts +11 -19
  107. package/src/signers/miden-wallet.ts +134 -7
  108. package/src/transaction/consumeNotes.ts +11 -1
  109. package/src/transaction/options.ts +4 -0
  110. package/src/transaction/p2id.test.ts +98 -8
  111. package/src/transaction/p2id.ts +64 -4
  112. package/src/transaction/summary.ts +12 -0
  113. package/src/transaction/updateGuardian.ts +11 -1
  114. package/src/transaction/updateProcedureThreshold.ts +13 -1
  115. package/src/transaction/updateSigners.ts +13 -1
  116. package/src/transaction.ts +3 -0
  117. package/src/types/proposal.ts +9 -0
  118. package/src/utils/ecdsa.test.ts +60 -0
  119. package/src/utils/ecdsa.ts +51 -1
  120. package/dist/account/masm/authLibraries.d.ts +0 -6
  121. package/dist/account/masm/authLibraries.d.ts.map +0 -1
  122. package/dist/account/masm/authLibraries.js +0 -17
  123. package/dist/account/masm/authLibraries.js.map +0 -1
  124. package/dist/multisig/proposal/execution.d.ts +0 -20
  125. package/dist/multisig/proposal/execution.d.ts.map +0 -1
  126. package/dist/multisig/proposal/execution.js +0 -166
  127. package/dist/multisig/proposal/execution.js.map +0 -1
  128. package/dist/multisig/proposal/parser.d.ts +0 -17
  129. package/dist/multisig/proposal/parser.d.ts.map +0 -1
  130. package/dist/multisig/proposal/parser.js +0 -147
  131. package/dist/multisig/proposal/parser.js.map +0 -1
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { secp256k1 } from '@noble/curves/secp256k1';
3
+ import { keccak_256 } from '@noble/hashes/sha3.js';
4
+
5
+ import { MidenWalletSigner, type WalletSigningContext } from './miden-wallet.js';
6
+ import type { Signer } from '../types.js';
7
+ import { tryComputeEcdsaCommitmentHex } from '../utils/signature.js';
8
+ import { bytesToHex, hexToBytes } from '../utils/encoding.js';
9
+ import { buildGuardianSignatureFromSigner } from '../multisig/signing.js';
10
+
11
+ // Real-crypto repro for the reported failure:
12
+ // "assertion failed with error message: invalid public key commitment"
13
+ //
14
+ // These tests use the real WASM SDK (Poseidon2/PublicKey, via tests/setup-wasm.ts)
15
+ // and real secp256k1 keypairs — NOT mocks — so they exercise the exact kernel
16
+ // invariant `Poseidon2(publicKey) == pub_key_commitment` that the transaction
17
+ // kernel enforces in `ecdsa_k256_keccak.masm` (`assert_eqw "invalid public key
18
+ // commitment"`). A wallet-delegated ECDSA cosigner whose `publicKey` degrades to
19
+ // the 32-byte commitment — or is a valid-but-wrong key — fails that invariant.
20
+
21
+ // Deterministic keys so the repro is reproducible run-to-run.
22
+ const PRIVATE_KEY = hexToBytes('0x' + '11'.repeat(32));
23
+ const COMPRESSED_PUBKEY_HEX = bytesToHex(secp256k1.getPublicKey(PRIVATE_KEY, true));
24
+
25
+ // A second, unrelated key: valid secp256k1 but NOT the one the commitment is for.
26
+ const OTHER_PRIVATE_KEY = hexToBytes('0x' + '22'.repeat(32));
27
+ const OTHER_COMPRESSED_PUBKEY_HEX = bytesToHex(secp256k1.getPublicKey(OTHER_PRIVATE_KEY, true));
28
+
29
+ /** Asserts a nullable value is present and returns it narrowed (avoids `!`). */
30
+ function expectPresent<T>(value: T | null | undefined): T {
31
+ expect(value ?? null).not.toBeNull();
32
+ return value as T;
33
+ }
34
+
35
+ /**
36
+ * A wallet that signs like the real Miden Wallet ECDSA path: it keccak256-hashes
37
+ * the word bytes it receives, produces a 65-byte `r||s||v` signature, and prepends
38
+ * a 1-byte scheme tag (which `MidenWalletSigner.signWord` strips via `slice(1)`).
39
+ */
40
+ function ecdsaWallet(privateKey: Uint8Array): WalletSigningContext {
41
+ return {
42
+ async signBytes(data: Uint8Array): Promise<Uint8Array> {
43
+ const msgHash = keccak_256(data);
44
+ const sig = secp256k1.sign(msgHash, privateKey);
45
+ const compact = sig.toCompactRawBytes(); // 64 bytes: r || s
46
+ const tagged = new Uint8Array(1 + 65);
47
+ tagged[0] = 1; // scheme tag, dropped by the signer
48
+ tagged.set(compact, 1);
49
+ tagged[1 + 64] = sig.recovery; // recovery byte v
50
+ return tagged;
51
+ },
52
+ };
53
+ }
54
+
55
+ /** Minimal ECDSA delegate (`localAuthSigner`) carrying a fixed public key. */
56
+ function ecdsaDelegate(commitment: string, publicKey: string): Signer {
57
+ return {
58
+ commitment,
59
+ publicKey,
60
+ scheme: 'ecdsa',
61
+ signAccountIdWithTimestamp: async () => '0x00',
62
+ signCommitment: async () => '0x00',
63
+ };
64
+ }
65
+
66
+ describe('MidenWalletSigner ECDSA public-key resolution', () => {
67
+ it('resolves the real ECDSA public key from its own signature so it hashes to the account commitment', async () => {
68
+ // The account's stored commitment is Poseidon2(pubkey) — exactly what the kernel checks.
69
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
70
+
71
+ // Wallet-delegated ECDSA cosigner constructed WITHOUT an explicit public key —
72
+ // the reported failure path, where `publicKey` silently fell back to the commitment.
73
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa');
74
+
75
+ await signer.signCommitment(accountCommitment);
76
+
77
+ // The resolved public key must be the real 33-byte key, not the 32-byte commitment...
78
+ expect(signer.publicKey).toBe(COMPRESSED_PUBKEY_HEX);
79
+ expect(signer.publicKey).not.toBe(accountCommitment);
80
+ // ...and it must Poseidon2-hash back to the commitment (the kernel's assert_eqw).
81
+ expect(tryComputeEcdsaCommitmentHex(signer.publicKey)).toBe(accountCommitment);
82
+ });
83
+
84
+ it('fails closed when the wallet signature recovers a key that does not match the commitment', async () => {
85
+ // The account commitment is for PRIVATE_KEY, but the wallet signs with a
86
+ // different key. Recovery must reject the mismatch rather than trust it.
87
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
88
+
89
+ const signer = new MidenWalletSigner(ecdsaWallet(OTHER_PRIVATE_KEY), accountCommitment, 'ecdsa');
90
+ await signer.signCommitment(accountCommitment);
91
+
92
+ expect(() => signer.publicKey).toThrow(/does not match the signer commitment/);
93
+ });
94
+
95
+ it('accepts an explicit ECDSA public key whose commitment matches, without needing to sign', () => {
96
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
97
+ const signer = new MidenWalletSigner(
98
+ ecdsaWallet(PRIVATE_KEY),
99
+ accountCommitment,
100
+ 'ecdsa',
101
+ undefined,
102
+ COMPRESSED_PUBKEY_HEX,
103
+ );
104
+ expect(signer.publicKey).toBe(COMPRESSED_PUBKEY_HEX);
105
+ expect(tryComputeEcdsaCommitmentHex(signer.publicKey)).toBe(accountCommitment);
106
+ });
107
+
108
+ it('rejects an explicit ECDSA public key whose commitment does not match the signer commitment', () => {
109
+ // A well-formed 33-byte key (passes format validation) that belongs to a
110
+ // different account. It must be rejected at the source, not carried into signing.
111
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
112
+ const signer = new MidenWalletSigner(
113
+ ecdsaWallet(PRIVATE_KEY),
114
+ accountCommitment,
115
+ 'ecdsa',
116
+ undefined,
117
+ OTHER_COMPRESSED_PUBKEY_HEX,
118
+ );
119
+ expect(() => signer.publicKey).toThrow(/does not match the signer commitment/);
120
+ });
121
+
122
+ it('rejects an explicit ECDSA key that is a valid length but not a secp256k1 point', () => {
123
+ // 33-byte, correct compressed tag, but x is off-curve — must be rejected at
124
+ // construction, SDK-independently, not silently carried to execute time.
125
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
126
+ expect(
127
+ () =>
128
+ new MidenWalletSigner(
129
+ ecdsaWallet(PRIVATE_KEY),
130
+ accountCommitment,
131
+ 'ecdsa',
132
+ undefined,
133
+ '0x02' + 'ff'.repeat(32),
134
+ ),
135
+ ).toThrow(/not a valid secp256k1 point/);
136
+ });
137
+
138
+ it('compresses an explicitly-provided 65-byte uncompressed public key', () => {
139
+ const uncompressed = bytesToHex(secp256k1.getPublicKey(PRIVATE_KEY, false)); // 65 bytes
140
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
141
+ const signer = new MidenWalletSigner(
142
+ ecdsaWallet(PRIVATE_KEY),
143
+ accountCommitment,
144
+ 'ecdsa',
145
+ undefined,
146
+ uncompressed,
147
+ );
148
+ expect(signer.publicKey).toBe(COMPRESSED_PUBKEY_HEX);
149
+ });
150
+
151
+ it('accepts a localAuthSigner whose ECDSA public key matches the commitment', () => {
152
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
153
+ const delegate = ecdsaDelegate(accountCommitment, COMPRESSED_PUBKEY_HEX);
154
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa', delegate);
155
+ expect(signer.publicKey).toBe(COMPRESSED_PUBKEY_HEX);
156
+ expect(tryComputeEcdsaCommitmentHex(signer.publicKey)).toBe(accountCommitment);
157
+ });
158
+
159
+ it('rejects a localAuthSigner whose ECDSA public key does not match the commitment', () => {
160
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
161
+ const delegate = ecdsaDelegate(accountCommitment, OTHER_COMPRESSED_PUBKEY_HEX);
162
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa', delegate);
163
+ expect(() => signer.publicKey).toThrow(/does not match the signer commitment/);
164
+ });
165
+
166
+ it('rejects a localAuthSigner whose ECDSA key is a valid length but not a secp256k1 point', () => {
167
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
168
+ const delegate = ecdsaDelegate(accountCommitment, '0x02' + 'ff'.repeat(32));
169
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa', delegate);
170
+ expect(() => signer.publicKey).toThrow(/not a valid secp256k1 point/);
171
+ });
172
+
173
+ it('resolves the public key through a non-signCommitment path (signLookupMessage)', async () => {
174
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
175
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa');
176
+
177
+ await signer.signLookupMessage(accountCommitment, 1700000000);
178
+
179
+ expect(signer.publicKey).toBe(COMPRESSED_PUBKEY_HEX);
180
+ });
181
+
182
+ it('builds a guardian ProposalSignature carrying the recovered key (integration via buildGuardianSignatureFromSigner)', async () => {
183
+ const accountCommitment = expectPresent(tryComputeEcdsaCommitmentHex(COMPRESSED_PUBKEY_HEX));
184
+ const signer = new MidenWalletSigner(ecdsaWallet(PRIVATE_KEY), accountCommitment, 'ecdsa');
185
+
186
+ // The exact call the guardian flow makes (multisig/signing.ts): sign the
187
+ // commitment, then read `signer.publicKey` for the ProposalSignature that is sent
188
+ // to the guardian and packed into the transaction advice map. This is the seam
189
+ // where the report observed `publicKey === commitment`.
190
+ const proposalSignature = await buildGuardianSignatureFromSigner(signer, accountCommitment);
191
+
192
+ expect(proposalSignature.scheme).toBe('ecdsa');
193
+ if (proposalSignature.scheme !== 'ecdsa') throw new Error('expected an ecdsa ProposalSignature');
194
+ // The signature reaching the guardian/kernel must carry the real 33-byte key,
195
+ // not the 32-byte commitment — otherwise Poseidon2(publicKey) == commitment fails.
196
+ const resolvedKey = expectPresent(proposalSignature.publicKey);
197
+ expect(resolvedKey).toBe(COMPRESSED_PUBKEY_HEX);
198
+ expect(resolvedKey).not.toBe(accountCommitment);
199
+ expect(tryComputeEcdsaCommitmentHex(resolvedKey)).toBe(accountCommitment);
200
+ });
201
+ });
@@ -54,28 +54,20 @@ describe('MidenWalletSigner', () => {
54
54
  expect(signer.publicKey).toBe('0xcommitment');
55
55
  });
56
56
 
57
- it('should use explicit publicKey when provided', () => {
58
- const signer = new MidenWalletSigner(
59
- mockWallet,
60
- '0xcommitment',
61
- 'ecdsa',
62
- undefined,
63
- '0xwalletpubkey',
64
- );
65
- expect(signer.publicKey).toBe('0xwalletpubkey');
57
+ it('throws on an invalid explicit ECDSA publicKey instead of falling back to the commitment', () => {
58
+ expect(
59
+ () => new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', undefined, '0xwalletpubkey'),
60
+ ).toThrow(/invalid ECDSA public key/);
66
61
  });
67
62
 
68
- it('should use localAuthSigner publicKey when provided', () => {
69
- const localSigner: Signer = {
70
- commitment: '0xlocal',
71
- publicKey: '0xlocalpubkey',
72
- scheme: 'ecdsa',
73
- signAccountIdWithTimestamp: vi.fn(),
74
- signCommitment: vi.fn(),
75
- };
76
- const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', localSigner);
77
- expect(signer.publicKey).toBe('0xlocalpubkey');
63
+ it('does not fall back to the commitment for an ECDSA signer with no key (throws until a signature exists)', () => {
64
+ const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa');
65
+ expect(() => signer.publicKey).toThrow(/not available yet/);
78
66
  });
67
+
68
+ // ECDSA publicKey resolution against a real commitment (explicit key and
69
+ // localAuthSigner, match and mismatch) needs real Poseidon2 and is covered in
70
+ // miden-wallet.ecdsa-recovery.test.ts, which runs the WASM SDK.
79
71
  });
80
72
 
81
73
  describe('signAccountIdWithTimestamp', () => {
@@ -1,20 +1,39 @@
1
1
  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
- import { bytesToHex } from '../utils/encoding.js';
4
+ import { EcdsaFormat } from '../utils/ecdsa.js';
5
+ import { bytesToHex, normalizeHexWord } from '../utils/encoding.js';
5
6
  import { lookupAuthDigest } from '../lookupAuth.js';
7
+ import { tryComputeEcdsaCommitmentHex } from '../utils/signature.js';
6
8
  import { wordToBytes } from '../utils/word.js';
7
9
 
8
10
  export interface WalletSigningContext {
11
+ /**
12
+ * Sign `data` with the wallet's key.
13
+ *
14
+ * `signBytes` MUST return the signature in the Miden SDK's serialized form: a
15
+ * 1-byte scheme tag followed by the raw signature. The signer always strips that
16
+ * leading tag byte, exactly as the SDK's own `EcdsaSigner`/`FalconSigner` do with
17
+ * `Signature.serialize()`.
18
+ *
19
+ * For an ECDSA (secp256k1/keccak) signer constructed WITHOUT an explicit public
20
+ * key and without a `localAuthSigner`, the raw signature MUST carry the recovery
21
+ * byte — the 65-byte `r||s||v` form (66 bytes including the tag) — so the public
22
+ * key can be recovered from it. A 64-byte signature with no recovery byte cannot
23
+ * be recovered, and the signer fails closed when `publicKey` is read.
24
+ */
9
25
  signBytes(data: Uint8Array, kind: 'word' | 'signingInputs'): Promise<Uint8Array>;
10
26
  }
11
27
 
12
28
  export class MidenWalletSigner implements Signer {
13
29
  readonly commitment: string;
14
- readonly publicKey: string;
15
30
  readonly scheme: SignatureScheme;
16
31
  private readonly wallet: WalletSigningContext;
17
32
  private readonly localAuthSigner: Signer | null;
33
+ private readonly explicitPublicKey: string | null;
34
+ private resolvedPublicKey: string | null;
35
+ private lastSignedMessage: Uint8Array | null = null;
36
+ private lastSignature: Uint8Array | null = null;
18
37
 
19
38
  constructor(
20
39
  wallet: WalletSigningContext,
@@ -27,7 +46,86 @@ export class MidenWalletSigner implements Signer {
27
46
  this.commitment = commitment;
28
47
  this.scheme = scheme;
29
48
  this.localAuthSigner = localAuthSigner ?? null;
30
- this.publicKey = publicKey ?? localAuthSigner?.publicKey ?? commitment;
49
+
50
+ if (scheme === 'ecdsa') {
51
+ this.explicitPublicKey = publicKey == null ? null : validateEcdsaPublicKeyHex(publicKey);
52
+ this.resolvedPublicKey = null;
53
+ } else {
54
+ this.explicitPublicKey = null;
55
+ this.resolvedPublicKey = publicKey ?? localAuthSigner?.publicKey ?? commitment;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The signer's public key.
61
+ *
62
+ * Returns immediately for Falcon. For ECDSA the key is resolved from one of
63
+ * three sources — an explicit key, a delegate `localAuthSigner`, or recovery
64
+ * from a signature the signer has already produced — and every source is
65
+ * verified against {@link commitment} via the same `Poseidon2(publicKey) ==
66
+ * commitment` invariant the transaction kernel enforces. Reading `publicKey`
67
+ * before any source is available, or when the resolved key does not match the
68
+ * commitment, throws: a loud sign-time failure instead of an opaque kernel
69
+ * assertion ("invalid public key commitment") at execute time.
70
+ */
71
+ get publicKey(): string {
72
+ if (this.resolvedPublicKey !== null) {
73
+ return this.resolvedPublicKey;
74
+ }
75
+
76
+ if (this.explicitPublicKey !== null) {
77
+ return this.verifyAndCache(this.explicitPublicKey, false);
78
+ }
79
+
80
+ if (this.localAuthSigner !== null) {
81
+ return this.verifyAndCache(validateEcdsaPublicKeyHex(this.localAuthSigner.publicKey), false);
82
+ }
83
+
84
+ if (this.lastSignature === null || this.lastSignedMessage === null) {
85
+ throw new Error(
86
+ 'MidenWalletSigner: ECDSA public key is not available yet — produce a signature first, ' +
87
+ 'or pass `publicKey` to the constructor.',
88
+ );
89
+ }
90
+
91
+ const recovered = EcdsaFormat.recoverCompressedPublicKeyHex(
92
+ this.lastSignedMessage,
93
+ this.lastSignature,
94
+ );
95
+ return this.verifyAndCache(recovered, true);
96
+ }
97
+
98
+ /**
99
+ * Verify an already point-validated ECDSA public-key candidate against
100
+ * {@link commitment} and cache it on success.
101
+ *
102
+ * A mismatching commitment is always rejected. `requireCommitment` distinguishes
103
+ * the recovery path — where a signature already exists, so the SDK must be
104
+ * initialized and an uncomputable commitment is an error — from the
105
+ * explicit/delegate paths, where a caller may read `publicKey` before the SDK is
106
+ * initialized. In that cold-SDK case the commitment cannot be computed yet, so
107
+ * the candidate is returned WITHOUT being cached and verification is re-attempted
108
+ * on the next read (once the SDK is up, as it always is in the guardian flow).
109
+ */
110
+ private verifyAndCache(candidate: string, requireCommitment: boolean): string {
111
+ const candidateCommitment = tryComputeEcdsaCommitmentHex(candidate);
112
+ if (candidateCommitment === null) {
113
+ if (requireCommitment) {
114
+ throw new Error(
115
+ 'MidenWalletSigner: could not compute a commitment for the recovered ECDSA public key ' +
116
+ '(is the Miden SDK initialized?).',
117
+ );
118
+ }
119
+ return candidate;
120
+ }
121
+ if (candidateCommitment !== normalizeHexWord(this.commitment)) {
122
+ throw new Error(
123
+ 'MidenWalletSigner: the ECDSA public key does not match the signer commitment.',
124
+ );
125
+ }
126
+
127
+ this.resolvedPublicKey = candidate;
128
+ return candidate;
31
129
  }
32
130
 
33
131
  async signAccountIdWithTimestamp(accountId: string, timestamp: number): Promise<string> {
@@ -46,10 +144,6 @@ export class MidenWalletSigner implements Signer {
46
144
  if (this.localAuthSigner?.signRequest) {
47
145
  return this.localAuthSigner.signRequest(accountId, timestamp, requestPayload);
48
146
  }
49
-
50
- if (this.scheme === 'falcon') {
51
- return this.signWord(AuthDigest.fromRequest(accountId, timestamp, requestPayload));
52
- }
53
147
  return this.signWord(AuthDigest.fromRequest(accountId, timestamp, requestPayload));
54
148
  }
55
149
 
@@ -74,6 +168,39 @@ export class MidenWalletSigner implements Signer {
74
168
  const bytes = wordToBytes(word);
75
169
  const signatureBytes = await this.wallet.signBytes(bytes, 'word');
76
170
  const rawSignature = signatureBytes.slice(1);
171
+ if (
172
+ this.scheme === 'ecdsa' &&
173
+ this.explicitPublicKey === null &&
174
+ this.localAuthSigner === null
175
+ ) {
176
+ this.lastSignedMessage = bytes;
177
+ this.lastSignature = rawSignature;
178
+ }
77
179
  return bytesToHex(rawSignature);
78
180
  }
79
181
  }
182
+
183
+ /**
184
+ * Validate an ECDSA public key and return its compressed (33-byte) form.
185
+ *
186
+ * The key must be a 33-/65-byte hex key AND a real secp256k1 curve point; a
187
+ * malformed or off-curve key throws immediately (SDK-independent) rather than
188
+ * degrading to the commitment or surfacing as an opaque kernel assertion at
189
+ * execute time. Mirrors the format handling ParaSigner does for explicit keys.
190
+ */
191
+ function validateEcdsaPublicKeyHex(publicKey: string): string {
192
+ if (!EcdsaFormat.validatePublicKeyHex(publicKey)) {
193
+ throw new Error(
194
+ 'MidenWalletSigner: invalid ECDSA public key (expected a 33- or 65-byte hex key); ' +
195
+ 'refusing to fall back to the commitment.',
196
+ );
197
+ }
198
+ const compressed = EcdsaFormat.compressPublicKey(publicKey);
199
+ if (!EcdsaFormat.isValidPublicKeyPoint(compressed)) {
200
+ throw new Error(
201
+ 'MidenWalletSigner: invalid ECDSA public key (not a valid secp256k1 point); ' +
202
+ 'refusing to fall back to the commitment.',
203
+ );
204
+ }
205
+ return compressed;
206
+ }
@@ -15,7 +15,7 @@ import { LegacyConsumeNotesNoteMissingError } from '../multisig/consumeNotesErro
15
15
  import { getRawMidenClient } from '../raw-client.js';
16
16
  import { normalizeHexWord } from '../utils/encoding.js';
17
17
  import { randomWord } from '../utils/random.js';
18
- import type { SignatureOptions } from './options.js';
18
+ import type { MidenClientSignatureOptions, SignatureOptions } from './options.js';
19
19
 
20
20
  /**
21
21
  * Build a consume-notes request from loaded `Note` objects (no local-store
@@ -57,6 +57,16 @@ export function buildConsumeNotesTransactionRequestFromNotes(
57
57
  * Legacy/creation adapter: fetches notes from the local store and delegates
58
58
  * to the from-notes variant. v2 verification MUST NOT call this.
59
59
  */
60
+ export function buildConsumeNotesTransactionRequest(
61
+ client: MidenClient,
62
+ noteIds: string[],
63
+ options: MidenClientSignatureOptions,
64
+ ): Promise<{ request: TransactionRequest; salt: Word }>;
65
+ export function buildConsumeNotesTransactionRequest(
66
+ client: WasmWebClient,
67
+ noteIds: string[],
68
+ options?: SignatureOptions,
69
+ ): Promise<{ request: TransactionRequest; salt: Word }>;
60
70
  export async function buildConsumeNotesTransactionRequest(
61
71
  client: MidenClient | WasmWebClient,
62
72
  noteIds: string[],
@@ -7,3 +7,7 @@ export interface SignatureOptions {
7
7
  signatureScheme?: SignatureScheme;
8
8
  midenRpcEndpoint?: string;
9
9
  }
10
+
11
+ export interface MidenClientSignatureOptions extends SignatureOptions {
12
+ midenRpcEndpoint: string;
13
+ }
@@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import type { Word } from '@miden-sdk/miden-sdk';
3
3
 
4
4
  const {
5
+ mockFromVaultKey,
6
+ mockFungibleAssetConstructor,
5
7
  mockHashElements,
6
8
  mockNormalizeHexWord,
7
9
  mockRandomWord,
8
10
  mockWordFromHex,
11
+ noteMetadataCalls,
9
12
  saltFelts,
10
13
  } = vi.hoisted(() => {
11
14
  const saltFelts = [
@@ -16,6 +19,13 @@ const {
16
19
  ];
17
20
 
18
21
  return {
22
+ mockFromVaultKey: vi.fn((vaultKey: unknown, amount: bigint) => ({
23
+ kind: 'asset-from-vault-key',
24
+ vaultKey,
25
+ amount,
26
+ })),
27
+ mockFungibleAssetConstructor: vi.fn(),
28
+ noteMetadataCalls: [] as unknown[][],
19
29
  mockHashElements: vi.fn().mockReturnValue({ toString: () => 'serial' }),
20
30
  mockNormalizeHexWord: vi.fn((hex: string) => hex),
21
31
  mockRandomWord: vi.fn().mockReturnValue({
@@ -66,10 +76,12 @@ vi.mock('@miden-sdk/miden-sdk', () => {
66
76
 
67
77
  class NoteMetadata {
68
78
  constructor(
69
- _sender: unknown,
70
- _noteType: unknown,
71
- _noteTag: unknown,
72
- ) {}
79
+ sender: unknown,
80
+ noteType: unknown,
81
+ noteTag: unknown,
82
+ ) {
83
+ noteMetadataCalls.push([sender, noteType, noteTag]);
84
+ }
73
85
  }
74
86
 
75
87
  class NoteRecipient {
@@ -89,7 +101,11 @@ vi.mock('@miden-sdk/miden-sdk', () => {
89
101
  }
90
102
 
91
103
  class FungibleAsset {
92
- constructor(_faucet: unknown, _amount: bigint) {}
104
+ constructor(faucet: unknown, amount: bigint) {
105
+ mockFungibleAssetConstructor(faucet, amount);
106
+ }
107
+
108
+ static fromVaultKey = mockFromVaultKey;
93
109
  }
94
110
 
95
111
  class NoteArray {
@@ -120,6 +136,7 @@ vi.mock('@miden-sdk/miden-sdk', () => {
120
136
  hex,
121
137
  prefix: () => 1,
122
138
  suffix: () => 2,
139
+ toString: () => hex,
123
140
  })),
124
141
  },
125
142
  Felt,
@@ -140,7 +157,8 @@ vi.mock('@miden-sdk/miden-sdk', () => {
140
157
  withAccountTarget: vi.fn(() => ({ kind: 'tag' })),
141
158
  },
142
159
  NoteType: {
143
- Public: 'public',
160
+ Private: 0,
161
+ Public: 1,
144
162
  },
145
163
  OutputNote: {
146
164
  full: vi.fn((note: unknown) => ({ note })),
@@ -163,14 +181,32 @@ vi.mock('../utils/random.js', () => ({
163
181
  randomWord: mockRandomWord,
164
182
  }));
165
183
 
166
- import { buildP2idTransactionRequest } from './p2id.js';
184
+ import { buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata } from './p2id.js';
185
+ import type { Account } from '@miden-sdk/miden-sdk';
186
+
187
+ const FAUCET_ID = '0x7bfb0f38b0fafa103f86a805594171';
188
+
189
+ const mockAccount = {
190
+ vault: () => ({
191
+ fungibleAssets: () => [
192
+ {
193
+ faucetId: () => ({ toString: () => FAUCET_ID }),
194
+ vaultKey: () => ({ kind: 'vault-key' }),
195
+ },
196
+ ],
197
+ }),
198
+ } as unknown as Account;
199
+ import { NoteType } from '@miden-sdk/miden-sdk';
167
200
 
168
201
  describe('buildP2idTransactionRequest', () => {
169
202
  beforeEach(() => {
203
+ mockFromVaultKey.mockClear();
204
+ mockFungibleAssetConstructor.mockClear();
170
205
  mockHashElements.mockClear();
171
206
  mockNormalizeHexWord.mockClear();
172
207
  mockRandomWord.mockClear();
173
208
  mockWordFromHex.mockClear();
209
+ noteMetadataCalls.length = 0;
174
210
  });
175
211
 
176
212
  it('derives serial number from salt felts plus four zero felts', () => {
@@ -179,8 +215,9 @@ describe('buildP2idTransactionRequest', () => {
179
215
  buildP2idTransactionRequest(
180
216
  '0x7bfb0f38b0fafa103f86a805594170',
181
217
  '0x8a65fc5a39e4cd106d648e3eb4ab5f',
182
- '0x7bfb0f38b0fafa103f86a805594171',
218
+ FAUCET_ID,
183
219
  10n,
220
+ mockAccount,
184
221
  { salt },
185
222
  );
186
223
 
@@ -197,4 +234,57 @@ describe('buildP2idTransactionRequest', () => {
197
234
  expect((felt as { value: bigint }).value).toBe(0n);
198
235
  }
199
236
  });
237
+
238
+ it('creates a public note by default (issue #322)', () => {
239
+ buildP2idTransactionRequest(
240
+ '0x7bfb0f38b0fafa103f86a805594170',
241
+ '0x8a65fc5a39e4cd106d648e3eb4ab5f',
242
+ '0x7bfb0f38b0fafa103f86a805594171',
243
+ 10n,
244
+ mockAccount,
245
+ );
246
+
247
+ expect(noteMetadataCalls).toHaveLength(1);
248
+ expect(noteMetadataCalls[0][1]).toBe(NoteType.Public);
249
+ });
250
+
251
+ it('threads the requested noteType into the note metadata (issue #322)', () => {
252
+ buildP2idTransactionRequest(
253
+ '0x7bfb0f38b0fafa103f86a805594170',
254
+ '0x8a65fc5a39e4cd106d648e3eb4ab5f',
255
+ '0x7bfb0f38b0fafa103f86a805594171',
256
+ 10n,
257
+ mockAccount,
258
+ { noteType: NoteType.Private },
259
+ );
260
+
261
+ expect(noteMetadataCalls).toHaveLength(1);
262
+ expect(noteMetadataCalls[0][1]).toBe(NoteType.Private);
263
+ });
264
+ });
265
+
266
+ describe('parseP2idNoteType', () => {
267
+ it('maps absent to Public (pre-#322 proposals)', () => {
268
+ expect(parseP2idNoteType(undefined)).toBe(NoteType.Public);
269
+ });
270
+
271
+ it('maps wire values to note types', () => {
272
+ expect(parseP2idNoteType('public')).toBe(NoteType.Public);
273
+ expect(parseP2idNoteType('private')).toBe(NoteType.Private);
274
+ });
275
+
276
+ it('rejects unknown values instead of silently rebuilding a public note', () => {
277
+ expect(() => parseP2idNoteType('encrypted')).toThrow(/unsupported metadata.noteType/);
278
+ });
279
+ });
280
+
281
+ describe('p2idNoteTypeToMetadata', () => {
282
+ it('omits the default so public payloads keep the legacy wire shape', () => {
283
+ expect(p2idNoteTypeToMetadata(undefined)).toBeUndefined();
284
+ expect(p2idNoteTypeToMetadata(NoteType.Public)).toBeUndefined();
285
+ });
286
+
287
+ it('serializes private', () => {
288
+ expect(p2idNoteTypeToMetadata(NoteType.Private)).toBe('private');
289
+ });
200
290
  });