@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.
- package/README.md +25 -1
- package/dist/account/builder.d.ts +2 -1
- package/dist/account/builder.d.ts.map +1 -1
- package/dist/account/builder.js +1 -0
- package/dist/account/builder.js.map +1 -1
- package/dist/account/builder.test.js +2 -2
- package/dist/account/builder.test.js.map +1 -1
- package/dist/client.d.ts +9 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +46 -15
- package/dist/client.test.js.map +1 -1
- package/dist/connectivity.d.ts +38 -0
- package/dist/connectivity.d.ts.map +1 -0
- package/dist/connectivity.js +97 -0
- package/dist/connectivity.js.map +1 -0
- package/dist/connectivity.test.d.ts +2 -0
- package/dist/connectivity.test.d.ts.map +1 -0
- package/dist/connectivity.test.js +61 -0
- package/dist/connectivity.test.js.map +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/multisig.d.ts +68 -7
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +90 -18
- package/dist/multisig.js.map +1 -1
- package/dist/multisig.test.js +214 -79
- package/dist/multisig.test.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +12 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/proposal/metadata.test.js +49 -0
- package/dist/proposal/metadata.test.js.map +1 -1
- package/dist/raw-client.d.ts +2 -2
- package/dist/raw-client.d.ts.map +1 -1
- package/dist/raw-client.js +14 -4
- package/dist/raw-client.js.map +1 -1
- package/dist/raw-client.test.js +25 -3
- package/dist/raw-client.test.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +44 -1
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts +2 -0
- package/dist/signers/miden-wallet.ecdsa-recovery.test.d.ts.map +1 -0
- package/dist/signers/miden-wallet.ecdsa-recovery.test.js +148 -0
- package/dist/signers/miden-wallet.ecdsa-recovery.test.js.map +1 -0
- package/dist/signers/miden-wallet.js +97 -6
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/miden-wallet.test.js +8 -13
- package/dist/signers/miden-wallet.test.js.map +1 -1
- package/dist/transaction/consumeNotes.d.ts +6 -2
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +0 -4
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/transaction/options.d.ts +3 -0
- package/dist/transaction/options.d.ts.map +1 -1
- package/dist/transaction/p2id.d.ts +20 -2
- package/dist/transaction/p2id.d.ts.map +1 -1
- package/dist/transaction/p2id.js +46 -3
- package/dist/transaction/p2id.js.map +1 -1
- package/dist/transaction/p2id.test.js +66 -6
- package/dist/transaction/p2id.test.js.map +1 -1
- package/dist/transaction/summary.d.ts +2 -1
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction/updateGuardian.d.ts +6 -2
- package/dist/transaction/updateGuardian.d.ts.map +1 -1
- package/dist/transaction/updateGuardian.js.map +1 -1
- package/dist/transaction/updateProcedureThreshold.d.ts +7 -2
- package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -1
- package/dist/transaction/updateProcedureThreshold.js.map +1 -1
- package/dist/transaction/updateSigners.d.ts +7 -2
- package/dist/transaction/updateSigners.d.ts.map +1 -1
- package/dist/transaction/updateSigners.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +5 -0
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +3 -0
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/ecdsa.d.ts +17 -0
- package/dist/utils/ecdsa.d.ts.map +1 -1
- package/dist/utils/ecdsa.js +44 -1
- package/dist/utils/ecdsa.js.map +1 -1
- package/dist/utils/ecdsa.test.js +46 -0
- package/dist/utils/ecdsa.test.js.map +1 -1
- package/package.json +12 -3
- package/src/account/builder.test.ts +19 -11
- package/src/account/builder.ts +2 -1
- package/src/client.test.ts +67 -15
- package/src/client.ts +18 -10
- package/src/connectivity.test.ts +67 -0
- package/src/connectivity.ts +111 -0
- package/src/index.ts +20 -1
- package/src/multisig.test.ts +267 -80
- package/src/multisig.ts +109 -20
- package/src/proposal/metadata.test.ts +60 -0
- package/src/proposal/metadata.ts +14 -0
- package/src/raw-client.test.ts +41 -3
- package/src/raw-client.ts +15 -5
- package/src/signers/miden-wallet.ecdsa-recovery.test.ts +201 -0
- package/src/signers/miden-wallet.test.ts +11 -19
- package/src/signers/miden-wallet.ts +134 -7
- package/src/transaction/consumeNotes.ts +11 -1
- package/src/transaction/options.ts +4 -0
- package/src/transaction/p2id.test.ts +98 -8
- package/src/transaction/p2id.ts +64 -4
- package/src/transaction/summary.ts +12 -0
- package/src/transaction/updateGuardian.ts +11 -1
- package/src/transaction/updateProcedureThreshold.ts +13 -1
- package/src/transaction/updateSigners.ts +13 -1
- package/src/transaction.ts +3 -0
- package/src/types/proposal.ts +9 -0
- package/src/utils/ecdsa.test.ts +60 -0
- package/src/utils/ecdsa.ts +51 -1
- package/dist/account/masm/authLibraries.d.ts +0 -6
- package/dist/account/masm/authLibraries.d.ts.map +0 -1
- package/dist/account/masm/authLibraries.js +0 -17
- package/dist/account/masm/authLibraries.js.map +0 -1
- package/dist/multisig/proposal/execution.d.ts +0 -20
- package/dist/multisig/proposal/execution.d.ts.map +0 -1
- package/dist/multisig/proposal/execution.js +0 -166
- package/dist/multisig/proposal/execution.js.map +0 -1
- package/dist/multisig/proposal/parser.d.ts +0 -17
- package/dist/multisig/proposal/parser.d.ts.map +0 -1
- package/dist/multisig/proposal/parser.js +0 -147
- 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('
|
|
58
|
-
|
|
59
|
-
mockWallet,
|
|
60
|
-
|
|
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('
|
|
69
|
-
const
|
|
70
|
-
|
|
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 {
|
|
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
|
-
|
|
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[],
|
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|