walletpair-sdk 1.0.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/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DAppSession } from './dapp-session.js';
|
|
3
|
+
import { WalletSession } from './wallet-session.js';
|
|
4
|
+
import { makeJoinBody, MockTransport, MockRelay } from './test-helpers.js';
|
|
5
|
+
import {
|
|
6
|
+
generateX25519KeyPair,
|
|
7
|
+
computeSharedSecret,
|
|
8
|
+
deriveSessionKey,
|
|
9
|
+
deriveDirectionalSessionKeys,
|
|
10
|
+
computeSessionFingerprint,
|
|
11
|
+
sealPayload,
|
|
12
|
+
b64urlEncode,
|
|
13
|
+
b64urlDecode,
|
|
14
|
+
parsePairingUri,
|
|
15
|
+
} from './crypto.js';
|
|
16
|
+
import type { AadHeader, SessionCryptoContext } from './crypto.js';
|
|
17
|
+
import type { ProtocolMessage } from './types.js';
|
|
18
|
+
|
|
19
|
+
function flushMicrotasks(): Promise<void> {
|
|
20
|
+
return new Promise((r) => setTimeout(r, 10));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dappPubKeyFromCreate(transport: MockTransport): string {
|
|
24
|
+
return transport.sent.find(m => m.t === 'create')!.from!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function receiveFreshJoin(
|
|
28
|
+
transport: MockTransport,
|
|
29
|
+
session: DAppSession,
|
|
30
|
+
walletKp: ReturnType<typeof generateX25519KeyPair>,
|
|
31
|
+
): void {
|
|
32
|
+
transport.receive({
|
|
33
|
+
v: 1, t: 'join', ch: session.channelId,
|
|
34
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
35
|
+
body: makeJoinBody(session.channelId, dappPubKeyFromCreate(transport), walletKp),
|
|
36
|
+
} as ProtocolMessage);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function receiveConnected(
|
|
40
|
+
transport: MockTransport,
|
|
41
|
+
session: DAppSession,
|
|
42
|
+
walletPubKeyB64: string,
|
|
43
|
+
): void {
|
|
44
|
+
transport.receive({
|
|
45
|
+
v: 1, t: 'ready', ch: session.channelId,
|
|
46
|
+
ts: Date.now(), from: '_adapter',
|
|
47
|
+
body: { state: 'connected', reconnect: false, remote: walletPubKeyB64 },
|
|
48
|
+
} as ProtocolMessage);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('DAppSession', () => {
|
|
52
|
+
let transport: MockTransport;
|
|
53
|
+
let session: DAppSession;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
transport = new MockTransport();
|
|
57
|
+
session = new DAppSession({ transport, meta: { name: 'Test dApp', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' } });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('createPairing', () => {
|
|
61
|
+
it('starts in idle phase', () => {
|
|
62
|
+
expect(session.phase).toBe('idle');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('creates pairing and transitions to waiting', async () => {
|
|
66
|
+
const phases: string[] = [];
|
|
67
|
+
session.on('phase', (p) => phases.push(p));
|
|
68
|
+
|
|
69
|
+
const uri = await session.createPairing();
|
|
70
|
+
expect(uri).toContain('walletpair:?ch=');
|
|
71
|
+
expect(uri).toContain('&pubkey=');
|
|
72
|
+
expect(session.phase).toBe('waiting');
|
|
73
|
+
expect(session.channelId).toHaveLength(64);
|
|
74
|
+
expect(session.pairingUri).toBe(uri);
|
|
75
|
+
expect(phases).toContain('waiting');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('emits pairingUri event', async () => {
|
|
79
|
+
const handler = vi.fn();
|
|
80
|
+
session.on('pairingUri', handler);
|
|
81
|
+
await session.createPairing();
|
|
82
|
+
expect(handler).toHaveBeenCalledWith(session.pairingUri);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('sends create message to transport', async () => {
|
|
86
|
+
await session.createPairing();
|
|
87
|
+
expect(transport.sent).toHaveLength(1);
|
|
88
|
+
expect(transport.sent[0]!.t).toBe('create');
|
|
89
|
+
expect(transport.sent[0]!.from).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('pairing URI is parseable', async () => {
|
|
93
|
+
await session.createPairing();
|
|
94
|
+
const parsed = parsePairingUri(session.pairingUri);
|
|
95
|
+
expect(parsed.ch).toBe(session.channelId);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('wallet join handling', () => {
|
|
100
|
+
let walletKp: ReturnType<typeof generateX25519KeyPair>;
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
await session.createPairing();
|
|
104
|
+
walletKp = generateX25519KeyPair();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('auto-accepts and transitions to accepting on join', async () => {
|
|
108
|
+
const phases: string[] = [];
|
|
109
|
+
session.on('phase', (p) => phases.push(p));
|
|
110
|
+
|
|
111
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
112
|
+
|
|
113
|
+
// With auto-accept, the session should not stay in pending_accept
|
|
114
|
+
// It should proceed to accepting (waiting for ready.connected)
|
|
115
|
+
expect(session.phase).not.toBe('idle');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('computes and emits session fingerprint on createPairing', async () => {
|
|
119
|
+
expect(session.sessionFingerprint).toMatch(/^\d{4}$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('emits walletJoined with capabilities and meta from sealed_join', async () => {
|
|
123
|
+
const handler = vi.fn();
|
|
124
|
+
session.on('walletJoined', handler);
|
|
125
|
+
|
|
126
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
127
|
+
|
|
128
|
+
expect(handler).toHaveBeenCalledWith({
|
|
129
|
+
capabilities: expect.objectContaining({ methods: expect.any(Array) }),
|
|
130
|
+
meta: expect.objectContaining({ name: 'Test Wallet' }),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('auto-accept on join', () => {
|
|
136
|
+
it('auto-accepts and transitions to connected on ready', async () => {
|
|
137
|
+
await session.createPairing();
|
|
138
|
+
const walletKp = generateX25519KeyPair();
|
|
139
|
+
|
|
140
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
141
|
+
|
|
142
|
+
// Should have auto-sent accept (no manual acceptWallet needed)
|
|
143
|
+
const acceptMsg = transport.sent.find(m => m.t === 'accept');
|
|
144
|
+
expect(acceptMsg).toBeTruthy();
|
|
145
|
+
expect((acceptMsg as any).body.target).toBe(walletKp.publicKeyB64);
|
|
146
|
+
|
|
147
|
+
// Simulate relay responding with ready.connected
|
|
148
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
149
|
+
|
|
150
|
+
expect(session.phase).toBe('connected');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects ready.connected with missing remote', async () => {
|
|
154
|
+
await session.createPairing();
|
|
155
|
+
const walletKp = generateX25519KeyPair();
|
|
156
|
+
const errorHandler = vi.fn();
|
|
157
|
+
session.on('error', errorHandler);
|
|
158
|
+
|
|
159
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
160
|
+
|
|
161
|
+
transport.receive({
|
|
162
|
+
v: 1, t: 'ready', ch: session.channelId,
|
|
163
|
+
ts: Date.now(), from: '_adapter',
|
|
164
|
+
body: { state: 'connected', reconnect: false, remote: null },
|
|
165
|
+
} as ProtocolMessage);
|
|
166
|
+
|
|
167
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
168
|
+
message: expect.stringContaining('remote does not match'),
|
|
169
|
+
}));
|
|
170
|
+
expect(session.phase).toBe('closed');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('rejectWallet', () => {
|
|
175
|
+
it('sends close with user_rejected and closes session (autoAccept disabled)', async () => {
|
|
176
|
+
const manualSession = new DAppSession({
|
|
177
|
+
transport, autoAccept: false,
|
|
178
|
+
meta: { name: 'Test dApp', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' },
|
|
179
|
+
});
|
|
180
|
+
await manualSession.createPairing();
|
|
181
|
+
const walletKp = generateX25519KeyPair();
|
|
182
|
+
|
|
183
|
+
receiveFreshJoin(transport, manualSession, walletKp);
|
|
184
|
+
|
|
185
|
+
manualSession.rejectWallet();
|
|
186
|
+
|
|
187
|
+
const closeMsg = transport.sent.find(m => m.t === 'close');
|
|
188
|
+
expect(closeMsg).toBeTruthy();
|
|
189
|
+
expect((closeMsg as any).body.reason).toBe('user_rejected');
|
|
190
|
+
expect(manualSession.phase).toBe('closed');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('request/response', () => {
|
|
195
|
+
let walletKp: ReturnType<typeof generateX25519KeyPair>;
|
|
196
|
+
let sessionKey: Uint8Array;
|
|
197
|
+
let walletToDappKey: Uint8Array;
|
|
198
|
+
|
|
199
|
+
beforeEach(async () => {
|
|
200
|
+
await session.createPairing();
|
|
201
|
+
walletKp = generateX25519KeyPair();
|
|
202
|
+
|
|
203
|
+
// Simulate join
|
|
204
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
205
|
+
|
|
206
|
+
// Derive session key from wallet side
|
|
207
|
+
const dappPubB64 = transport.sent[0]!.from!;
|
|
208
|
+
const dappPub = b64urlDecode(dappPubB64);
|
|
209
|
+
const shared = computeSharedSecret(walletKp.privateKey, dappPub);
|
|
210
|
+
sessionKey = deriveSessionKey(shared, session.channelId);
|
|
211
|
+
|
|
212
|
+
// Auto-accepted; simulate relay ready.connected
|
|
213
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
214
|
+
walletToDappKey = (session as any).recvKey;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('sends encrypted request', async () => {
|
|
218
|
+
const promise = session.request('wallet_getAccounts');
|
|
219
|
+
|
|
220
|
+
await flushMicrotasks();
|
|
221
|
+
|
|
222
|
+
const reqMsg = transport.sent.find(m => m.t === 'req');
|
|
223
|
+
expect(reqMsg).toBeTruthy();
|
|
224
|
+
const reqBody = (reqMsg as any).body;
|
|
225
|
+
expect(reqBody.id).toMatch(/^req-/);
|
|
226
|
+
expect(reqBody.sealed).toBeTruthy();
|
|
227
|
+
|
|
228
|
+
// Simulate wallet response
|
|
229
|
+
const resData = { _ok: true, _result: ['0xabc123'] };
|
|
230
|
+
const resHdr: AadHeader = { type: 'res', from: walletKp.publicKeyB64, id: reqBody.id };
|
|
231
|
+
transport.receive({
|
|
232
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
233
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
234
|
+
body: { id: reqBody.id, sealed: sealPayload(walletToDappKey, session.channelId, 0, resData, resHdr) },
|
|
235
|
+
} as ProtocolMessage);
|
|
236
|
+
|
|
237
|
+
const result = await promise;
|
|
238
|
+
expect(result).toEqual(['0xabc123']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('sends request with encrypted params', async () => {
|
|
242
|
+
const promise = session.request('wallet_signMessage', { message: 'Hello' });
|
|
243
|
+
await flushMicrotasks();
|
|
244
|
+
|
|
245
|
+
const reqMsg = transport.sent.find(m => m.t === 'req') as any;
|
|
246
|
+
expect(reqMsg.body.sealed).toBeTruthy(); // params were sealed
|
|
247
|
+
|
|
248
|
+
// Respond
|
|
249
|
+
const reqId = reqMsg.body.id;
|
|
250
|
+
transport.receive({
|
|
251
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
252
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
253
|
+
body: { id: reqId, sealed: sealPayload(walletToDappKey, session.channelId, 0, { _ok: true, _result: { signature: '0x...' } }, { type: 'res', from: walletKp.publicKeyB64, id: reqId }) },
|
|
254
|
+
} as ProtocolMessage);
|
|
255
|
+
|
|
256
|
+
const result = await promise;
|
|
257
|
+
expect(result).toEqual({ signature: '0x...' });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('rejects on error response', async () => {
|
|
261
|
+
const promise = session.request('wallet_signMessage', { message: 'Hi' });
|
|
262
|
+
await flushMicrotasks();
|
|
263
|
+
|
|
264
|
+
const reqMsg = transport.sent.find(m => m.t === 'req') as any;
|
|
265
|
+
const reqId = reqMsg.body.id;
|
|
266
|
+
|
|
267
|
+
transport.receive({
|
|
268
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
269
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
270
|
+
body: { id: reqId, sealed: sealPayload(walletToDappKey, session.channelId, 0, { _ok: false, code: 'user_rejected', message: 'User rejected' }, { type: 'res', from: walletKp.publicKeyB64, id: reqId }) },
|
|
271
|
+
} as ProtocolMessage);
|
|
272
|
+
|
|
273
|
+
await expect(promise).rejects.toThrow('User rejected');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('rejects on timeout', async () => {
|
|
277
|
+
vi.useFakeTimers();
|
|
278
|
+
|
|
279
|
+
const shortTimeoutSession = new DAppSession({
|
|
280
|
+
transport, meta: { name: 'Test', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' }, requestTimeout: 100,
|
|
281
|
+
});
|
|
282
|
+
// Manually set session state to connected
|
|
283
|
+
(shortTimeoutSession as any).phase = 'connected';
|
|
284
|
+
(shortTimeoutSession as any).sessionKey = sessionKey;
|
|
285
|
+
(shortTimeoutSession as any).sendKey = new Uint8Array(32).fill(1);
|
|
286
|
+
(shortTimeoutSession as any).channelId = session.channelId;
|
|
287
|
+
(shortTimeoutSession as any).pubKeyB64 = 'test';
|
|
288
|
+
|
|
289
|
+
const promise = shortTimeoutSession.request('wallet_getAccounts');
|
|
290
|
+
vi.advanceTimersByTime(200);
|
|
291
|
+
|
|
292
|
+
await expect(promise).rejects.toThrow('timed out');
|
|
293
|
+
vi.useRealTimers();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('emits response event', async () => {
|
|
297
|
+
const handler = vi.fn();
|
|
298
|
+
session.on('response', handler);
|
|
299
|
+
|
|
300
|
+
const promise = session.request('wallet_getAccounts');
|
|
301
|
+
await flushMicrotasks();
|
|
302
|
+
|
|
303
|
+
const reqMsg = transport.sent.find(m => m.t === 'req') as any;
|
|
304
|
+
const reqId = reqMsg.body.id;
|
|
305
|
+
transport.receive({
|
|
306
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
307
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
308
|
+
body: { id: reqId, sealed: sealPayload(walletToDappKey, session.channelId, 0, { _ok: true, _result: ['0x123'] }, { type: 'res', from: walletKp.publicKeyB64, id: reqId }) },
|
|
309
|
+
} as ProtocolMessage);
|
|
310
|
+
|
|
311
|
+
await promise;
|
|
312
|
+
expect(handler).toHaveBeenCalledWith({ id: reqId, ok: true, result: ['0x123'] });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('rejects request when not connected', async () => {
|
|
316
|
+
const idleSession = new DAppSession({ transport: new MockTransport(), meta: { name: 'Test', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' } });
|
|
317
|
+
await expect(idleSession.request('test')).rejects.toThrow('Not connected');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('event handling', () => {
|
|
322
|
+
it('emits event when wallet pushes evt', async () => {
|
|
323
|
+
await session.createPairing();
|
|
324
|
+
const walletKp = generateX25519KeyPair();
|
|
325
|
+
|
|
326
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
327
|
+
|
|
328
|
+
const dappPub = b64urlDecode(transport.sent[0]!.from!);
|
|
329
|
+
const shared = computeSharedSecret(walletKp.privateKey, dappPub);
|
|
330
|
+
deriveSessionKey(shared, session.channelId);
|
|
331
|
+
|
|
332
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
333
|
+
|
|
334
|
+
const handler = vi.fn();
|
|
335
|
+
session.on('event', handler);
|
|
336
|
+
|
|
337
|
+
const evtId = 'evt-1';
|
|
338
|
+
transport.receive({
|
|
339
|
+
v: 1, t: 'evt', ch: session.channelId,
|
|
340
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
341
|
+
body: { id: evtId, sealed: sealPayload((session as any).recvKey, session.channelId, 0, { _event: 'accountsChanged', accounts: ['0xabc'] }, { type: 'evt', from: walletKp.publicKeyB64, id: evtId }) },
|
|
342
|
+
} as ProtocolMessage);
|
|
343
|
+
|
|
344
|
+
expect(handler).toHaveBeenCalledWith({
|
|
345
|
+
event: 'accountsChanged',
|
|
346
|
+
data: { accounts: ['0xabc'] },
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('ping/pong', () => {
|
|
352
|
+
it('responds to ping with pong', async () => {
|
|
353
|
+
await session.createPairing();
|
|
354
|
+
const walletPubB64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
355
|
+
(session as any).remotePubKey = b64urlDecode(walletPubB64);
|
|
356
|
+
receiveConnected(transport, session, walletPubB64);
|
|
357
|
+
|
|
358
|
+
transport.receive({
|
|
359
|
+
v: 1, t: 'ping', ch: session.channelId,
|
|
360
|
+
ts: 1000, from: walletPubB64, body: {},
|
|
361
|
+
} as ProtocolMessage);
|
|
362
|
+
|
|
363
|
+
const pong = transport.sent.find(m => m.t === 'pong');
|
|
364
|
+
expect(pong).toBeTruthy();
|
|
365
|
+
expect(pong!.ts).toBeTypeOf('number');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('sends ping', async () => {
|
|
369
|
+
const walletPubB64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
370
|
+
await session.createPairing();
|
|
371
|
+
(session as any).remotePubKey = b64urlDecode(walletPubB64);
|
|
372
|
+
receiveConnected(transport, session, walletPubB64);
|
|
373
|
+
|
|
374
|
+
session.ping();
|
|
375
|
+
const ping = transport.sent.find(m => m.t === 'ping');
|
|
376
|
+
expect(ping).toBeTruthy();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('close', () => {
|
|
381
|
+
it('sends close message and transitions to closed', async () => {
|
|
382
|
+
await session.createPairing();
|
|
383
|
+
session.close();
|
|
384
|
+
|
|
385
|
+
const closeMsg = transport.sent.find(m => m.t === 'close');
|
|
386
|
+
expect(closeMsg).toBeTruthy();
|
|
387
|
+
expect((closeMsg as any).body.reason).toBe('normal');
|
|
388
|
+
expect(session.phase).toBe('closed');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('rejects all pending requests on close', async () => {
|
|
392
|
+
await session.createPairing();
|
|
393
|
+
const walletKp = generateX25519KeyPair();
|
|
394
|
+
|
|
395
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
396
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
397
|
+
|
|
398
|
+
const promise = session.request('test');
|
|
399
|
+
session.close();
|
|
400
|
+
|
|
401
|
+
await expect(promise).rejects.toThrow('Session closed');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('serialize/restore', () => {
|
|
406
|
+
it('round-trips session state', async () => {
|
|
407
|
+
await session.createPairing();
|
|
408
|
+
const walletKp = generateX25519KeyPair();
|
|
409
|
+
|
|
410
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
411
|
+
|
|
412
|
+
const json = session.serialize();
|
|
413
|
+
expect(json).toBeTruthy();
|
|
414
|
+
|
|
415
|
+
const newTransport = new MockTransport();
|
|
416
|
+
const restored = new DAppSession({ transport: newTransport, meta: { name: 'Test dApp', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' } });
|
|
417
|
+
expect(restored.restore(json)).toBe(true);
|
|
418
|
+
expect(restored.channelId).toBe(session.channelId);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('returns false for invalid JSON', () => {
|
|
422
|
+
const s = new DAppSession({ transport: new MockTransport(), meta: { name: 'Test dApp', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' } });
|
|
423
|
+
expect(s.restore('not json')).toBe(false);
|
|
424
|
+
expect(s.restore('{}')).toBe(false);
|
|
425
|
+
expect(s.restore('{"channelId":"abc"}')).toBe(false); // missing privKey
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('auto-accept on rejoin', () => {
|
|
430
|
+
it('auto-accepts known wallet on rejoin (no sealed_join on reconnect)', async () => {
|
|
431
|
+
await session.createPairing();
|
|
432
|
+
const walletKp = generateX25519KeyPair();
|
|
433
|
+
|
|
434
|
+
// First join carries sealed capabilities/meta (auto-accepted).
|
|
435
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
436
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
437
|
+
|
|
438
|
+
// Second join (rejoin) without sealed_join — should auto-accept (same wallet, same approved scope)
|
|
439
|
+
transport.receive({
|
|
440
|
+
v: 1, t: 'join', ch: session.channelId,
|
|
441
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
442
|
+
body: { sealed_join: null },
|
|
443
|
+
} as ProtocolMessage);
|
|
444
|
+
|
|
445
|
+
// First join auto-accepted + rejoin auto-accepted = 2 accept messages
|
|
446
|
+
const acceptMessages = transport.sent.filter(m => m.t === 'accept');
|
|
447
|
+
expect(acceptMessages).toHaveLength(2);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('auto-accepts new wallet on rejoin (different pubkey)', async () => {
|
|
451
|
+
await session.createPairing();
|
|
452
|
+
const walletKp = generateX25519KeyPair();
|
|
453
|
+
const walletKp2 = generateX25519KeyPair();
|
|
454
|
+
|
|
455
|
+
// First join (auto-accepted)
|
|
456
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
457
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
458
|
+
|
|
459
|
+
// Second join with different wallet — also auto-accepted (sealed_join decryption proves possession)
|
|
460
|
+
receiveFreshJoin(transport, session, walletKp2);
|
|
461
|
+
|
|
462
|
+
const acceptMessages = transport.sent.filter(m => m.t === 'accept');
|
|
463
|
+
expect(acceptMessages).toHaveLength(2);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe('close message handling', () => {
|
|
468
|
+
it('transitions to closed on receiving close', async () => {
|
|
469
|
+
await session.createPairing();
|
|
470
|
+
const walletKp = generateX25519KeyPair();
|
|
471
|
+
transport.receive({
|
|
472
|
+
v: 1, t: 'close', ch: session.channelId,
|
|
473
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
474
|
+
body: { reason: 'timeout' },
|
|
475
|
+
} as ProtocolMessage);
|
|
476
|
+
|
|
477
|
+
expect(session.phase).toBe('closed');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('destroy', () => {
|
|
482
|
+
it('closes and removes all listeners', async () => {
|
|
483
|
+
await session.createPairing();
|
|
484
|
+
const handler = vi.fn();
|
|
485
|
+
session.on('phase', handler);
|
|
486
|
+
session.destroy();
|
|
487
|
+
|
|
488
|
+
expect(session.phase).toBe('closed');
|
|
489
|
+
// After destroy, emitting should not call handler
|
|
490
|
+
// (removeAll was called)
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe('protocol compliance', () => {
|
|
495
|
+
it('rejects messages with from="_adapter" for peer types (§2)', async () => {
|
|
496
|
+
await session.createPairing();
|
|
497
|
+
const errorHandler = vi.fn();
|
|
498
|
+
session.on('error', errorHandler);
|
|
499
|
+
|
|
500
|
+
// Send a close message with from: '_adapter' — should be rejected
|
|
501
|
+
transport.receive({
|
|
502
|
+
v: 1, t: 'close', ch: session.channelId,
|
|
503
|
+
ts: Date.now(), from: '_adapter',
|
|
504
|
+
body: { reason: 'normal' },
|
|
505
|
+
} as ProtocolMessage);
|
|
506
|
+
|
|
507
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
508
|
+
message: expect.stringContaining('_adapter'),
|
|
509
|
+
}));
|
|
510
|
+
// Should NOT have processed it as a real close
|
|
511
|
+
expect(session.phase).not.toBe('closed');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('rejects messages with unsupported version (§15 rule 12)', async () => {
|
|
515
|
+
await session.createPairing();
|
|
516
|
+
|
|
517
|
+
// Send message with v: 2 — should close with unsupported_version
|
|
518
|
+
transport.receive({
|
|
519
|
+
v: 2, t: 'close', ch: session.channelId,
|
|
520
|
+
ts: Date.now(), from: 'somepubkey',
|
|
521
|
+
body: { reason: 'normal' },
|
|
522
|
+
} as unknown as ProtocolMessage);
|
|
523
|
+
|
|
524
|
+
expect(session.phase).toBe('closed');
|
|
525
|
+
const closeMsg = transport.sent.find(m => m.t === 'close') as any;
|
|
526
|
+
expect(closeMsg).toBeTruthy();
|
|
527
|
+
expect(closeMsg.body.reason).toBe('unsupported_version');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('uses null for missing walletMeta in session context', async () => {
|
|
531
|
+
await session.createPairing();
|
|
532
|
+
|
|
533
|
+
// Before any wallet joins, walletMeta should be undefined
|
|
534
|
+
expect(session.walletMeta).toBeUndefined();
|
|
535
|
+
|
|
536
|
+
// Access the private sessionContext to verify it uses null (not {})
|
|
537
|
+
const context = (session as any).sessionContext(
|
|
538
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
539
|
+
undefined,
|
|
540
|
+
undefined,
|
|
541
|
+
);
|
|
542
|
+
expect(context.walletMeta).toBeNull();
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('auto-accept flow (first join)', () => {
|
|
547
|
+
it('auto-accepts first wallet join with valid sealed_join, skipping pending_accept', async () => {
|
|
548
|
+
const phases: string[] = [];
|
|
549
|
+
session.on('phase', (p) => phases.push(p));
|
|
550
|
+
|
|
551
|
+
await session.createPairing();
|
|
552
|
+
const walletKp = generateX25519KeyPair();
|
|
553
|
+
|
|
554
|
+
receiveFreshJoin(transport, session, walletKp);
|
|
555
|
+
|
|
556
|
+
// Auto-accept should have sent an accept message without manual acceptWallet()
|
|
557
|
+
const acceptMsg = transport.sent.find(m => m.t === 'accept');
|
|
558
|
+
expect(acceptMsg).toBeTruthy();
|
|
559
|
+
|
|
560
|
+
// Simulate relay responding with ready.connected
|
|
561
|
+
receiveConnected(transport, session, walletKp.publicKeyB64);
|
|
562
|
+
|
|
563
|
+
expect(session.phase).toBe('connected');
|
|
564
|
+
// Phase goes waiting → pending_accept → (auto-accept) → accepting → connected
|
|
565
|
+
// pending_accept is emitted briefly before auto-accept kicks in
|
|
566
|
+
expect(phases).toContain('pending_accept');
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe('session fingerprint after createPairing', () => {
|
|
571
|
+
it('sessionFingerprint is a 4-digit string and event was emitted', async () => {
|
|
572
|
+
const fpHandler = vi.fn();
|
|
573
|
+
session.on('sessionFingerprint', fpHandler);
|
|
574
|
+
|
|
575
|
+
await session.createPairing();
|
|
576
|
+
|
|
577
|
+
expect(session.sessionFingerprint).toMatch(/^\d{4}$/);
|
|
578
|
+
expect(fpHandler).toHaveBeenCalledTimes(1);
|
|
579
|
+
expect(fpHandler).toHaveBeenCalledWith(session.sessionFingerprint);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('session fingerprint matches wallet side', () => {
|
|
584
|
+
it('dApp and wallet compute the same fingerprint', async () => {
|
|
585
|
+
await session.createPairing();
|
|
586
|
+
const dappFingerprint = session.sessionFingerprint;
|
|
587
|
+
|
|
588
|
+
// The dApp computes fingerprint from its own pubkey + channelId
|
|
589
|
+
// The wallet computes it from (channelId, dappPubKeyB64) — same inputs
|
|
590
|
+
const dappPubB64 = dappPubKeyFromCreate(transport);
|
|
591
|
+
const walletSideFingerprint = computeSessionFingerprint(session.channelId, dappPubB64);
|
|
592
|
+
|
|
593
|
+
expect(dappFingerprint).toBe(walletSideFingerprint);
|
|
594
|
+
expect(dappFingerprint).toMatch(/^\d{4}$/);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe('session TTL enforcement', () => {
|
|
599
|
+
it('closes with reason timeout after TTL expires', async () => {
|
|
600
|
+
vi.useFakeTimers();
|
|
601
|
+
|
|
602
|
+
const shortTtlTransport = new MockTransport();
|
|
603
|
+
const shortTtlSession = new DAppSession({
|
|
604
|
+
transport: shortTtlTransport,
|
|
605
|
+
meta: { name: 'T', description: 'T', url: 'https://t.com', icon: 'https://t.com/i.png' },
|
|
606
|
+
sessionTtl: 100,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
await shortTtlSession.createPairing();
|
|
610
|
+
const walletKp = generateX25519KeyPair();
|
|
611
|
+
|
|
612
|
+
// Simulate wallet join
|
|
613
|
+
shortTtlTransport.receive({
|
|
614
|
+
v: 1, t: 'join', ch: shortTtlSession.channelId,
|
|
615
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
616
|
+
body: makeJoinBody(shortTtlSession.channelId, shortTtlTransport.sent.find(m => m.t === 'create')!.from!, walletKp),
|
|
617
|
+
} as ProtocolMessage);
|
|
618
|
+
|
|
619
|
+
// Simulate ready.connected (this starts the TTL timer)
|
|
620
|
+
shortTtlTransport.receive({
|
|
621
|
+
v: 1, t: 'ready', ch: shortTtlSession.channelId,
|
|
622
|
+
ts: Date.now(), from: '_adapter',
|
|
623
|
+
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
624
|
+
} as ProtocolMessage);
|
|
625
|
+
|
|
626
|
+
expect(shortTtlSession.phase).toBe('connected');
|
|
627
|
+
|
|
628
|
+
const errorHandler = vi.fn();
|
|
629
|
+
shortTtlSession.on('error', errorHandler);
|
|
630
|
+
|
|
631
|
+
// Advance time past the TTL
|
|
632
|
+
vi.advanceTimersByTime(150);
|
|
633
|
+
|
|
634
|
+
expect(shortTtlSession.phase).toBe('closed');
|
|
635
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
636
|
+
message: expect.stringContaining('expired'),
|
|
637
|
+
}));
|
|
638
|
+
|
|
639
|
+
// Verify close message was sent with reason 'timeout'
|
|
640
|
+
const closeMsg = shortTtlTransport.sent.find(m => m.t === 'close');
|
|
641
|
+
expect(closeMsg).toBeTruthy();
|
|
642
|
+
expect((closeMsg as any).body.reason).toBe('timeout');
|
|
643
|
+
|
|
644
|
+
vi.useRealTimers();
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
});
|