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,196 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { frameMessage, Defragmenter, DEFAULT_FRAME_PAYLOAD, MIN_FRAME_PAYLOAD } from './framing.js';
|
|
3
|
+
|
|
4
|
+
describe('frameMessage', () => {
|
|
5
|
+
it('frames empty string as single frame with both flags', () => {
|
|
6
|
+
const frames = frameMessage('');
|
|
7
|
+
expect(frames).toHaveLength(1);
|
|
8
|
+
expect(frames[0]![0]).toBe(0x03); // first + last
|
|
9
|
+
expect(frames[0]).toHaveLength(3); // header only
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('frames short message as single frame', () => {
|
|
13
|
+
const msg = '{"v":1,"t":"ping"}';
|
|
14
|
+
const frames = frameMessage(msg);
|
|
15
|
+
expect(frames).toHaveLength(1);
|
|
16
|
+
expect(frames[0]![0]).toBe(0x03); // first + last
|
|
17
|
+
|
|
18
|
+
// Verify total length in header
|
|
19
|
+
const payload = new TextEncoder().encode(msg);
|
|
20
|
+
const totalLen = (frames[0]![1]! << 8) | frames[0]![2]!;
|
|
21
|
+
expect(totalLen).toBe(payload.length);
|
|
22
|
+
|
|
23
|
+
// Verify payload
|
|
24
|
+
const extracted = frames[0]!.subarray(3);
|
|
25
|
+
expect(new TextDecoder().decode(extracted)).toBe(msg);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('fragments message exceeding maxPayload', () => {
|
|
29
|
+
const longMsg = 'A'.repeat(100);
|
|
30
|
+
const frames = frameMessage(longMsg, 30);
|
|
31
|
+
|
|
32
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
33
|
+
|
|
34
|
+
// First frame: flag=0x01 (first only)
|
|
35
|
+
expect(frames[0]![0]! & 0x01).toBe(0x01); // first bit set
|
|
36
|
+
expect(frames[0]![0]! & 0x02).toBe(0x00); // last bit not set
|
|
37
|
+
|
|
38
|
+
// Last frame: flag=0x02 (last only)
|
|
39
|
+
const lastFrame = frames[frames.length - 1]!;
|
|
40
|
+
expect(lastFrame[0]! & 0x01).toBe(0x00); // first bit not set
|
|
41
|
+
expect(lastFrame[0]! & 0x02).toBe(0x02); // last bit set
|
|
42
|
+
|
|
43
|
+
// Middle frames (if any): flag=0x00
|
|
44
|
+
for (let i = 1; i < frames.length - 1; i++) {
|
|
45
|
+
expect(frames[i]![0]).toBe(0x00);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('first frame contains total length in header', () => {
|
|
50
|
+
const msg = 'X'.repeat(200);
|
|
51
|
+
const frames = frameMessage(msg, 50);
|
|
52
|
+
const totalLen = (frames[0]![1]! << 8) | frames[0]![2]!;
|
|
53
|
+
expect(totalLen).toBe(new TextEncoder().encode(msg).length);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('respects MIN_FRAME_PAYLOAD even when maxPayload is smaller', () => {
|
|
57
|
+
const msg = 'A'.repeat(100);
|
|
58
|
+
const frames = frameMessage(msg, 5); // smaller than MIN_FRAME_PAYLOAD
|
|
59
|
+
// Each payload chunk should be at least MIN_FRAME_PAYLOAD
|
|
60
|
+
for (const frame of frames) {
|
|
61
|
+
const payloadSize = frame.length - 3;
|
|
62
|
+
if (payloadSize > 0) {
|
|
63
|
+
// Last frame might be shorter
|
|
64
|
+
if ((frame[0]! & 0x02) === 0) {
|
|
65
|
+
expect(payloadSize).toBeGreaterThanOrEqual(MIN_FRAME_PAYLOAD);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses DEFAULT_FRAME_PAYLOAD when no maxPayload specified', () => {
|
|
72
|
+
const msg = 'B'.repeat(DEFAULT_FRAME_PAYLOAD + 100);
|
|
73
|
+
const frames = frameMessage(msg);
|
|
74
|
+
expect(frames.length).toBe(2); // slightly over one full frame
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('Defragmenter', () => {
|
|
79
|
+
it('reassembles single-frame message', () => {
|
|
80
|
+
const defrag = new Defragmenter();
|
|
81
|
+
const msg = '{"hello":"world"}';
|
|
82
|
+
const frames = frameMessage(msg);
|
|
83
|
+
|
|
84
|
+
expect(frames).toHaveLength(1);
|
|
85
|
+
const result = defrag.push(frames[0]!);
|
|
86
|
+
expect(result).toBe(msg);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reassembles multi-frame message', () => {
|
|
90
|
+
const defrag = new Defragmenter();
|
|
91
|
+
const msg = 'A'.repeat(200);
|
|
92
|
+
const frames = frameMessage(msg, 50);
|
|
93
|
+
|
|
94
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
97
|
+
const result = defrag.push(frames[i]!);
|
|
98
|
+
expect(result).toBeNull(); // not complete yet
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = defrag.push(frames[frames.length - 1]!);
|
|
102
|
+
expect(result).toBe(msg);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles multiple messages in sequence', () => {
|
|
106
|
+
const defrag = new Defragmenter();
|
|
107
|
+
|
|
108
|
+
const msg1 = '{"first":true}';
|
|
109
|
+
const msg2 = '{"second":true}';
|
|
110
|
+
|
|
111
|
+
const frames1 = frameMessage(msg1);
|
|
112
|
+
const frames2 = frameMessage(msg2);
|
|
113
|
+
|
|
114
|
+
expect(defrag.push(frames1[0]!)).toBe(msg1);
|
|
115
|
+
expect(defrag.push(frames2[0]!)).toBe(msg2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('handles interleaved fragmented messages correctly via reset', () => {
|
|
119
|
+
const defrag = new Defragmenter();
|
|
120
|
+
|
|
121
|
+
// Start a message
|
|
122
|
+
const longMsg = 'X'.repeat(100);
|
|
123
|
+
const frames = frameMessage(longMsg, 30);
|
|
124
|
+
defrag.push(frames[0]!); // first fragment
|
|
125
|
+
|
|
126
|
+
// Reset and start new message
|
|
127
|
+
defrag.reset();
|
|
128
|
+
const shortMsg = '{"ok":true}';
|
|
129
|
+
const shortFrames = frameMessage(shortMsg);
|
|
130
|
+
expect(defrag.push(shortFrames[0]!)).toBe(shortMsg);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('ignores frames shorter than 3 bytes', () => {
|
|
134
|
+
const defrag = new Defragmenter();
|
|
135
|
+
expect(defrag.push(new Uint8Array([0x03, 0x00]))).toBeNull();
|
|
136
|
+
expect(defrag.push(new Uint8Array([0x03]))).toBeNull();
|
|
137
|
+
expect(defrag.push(new Uint8Array([]))).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reassembles unicode content correctly', () => {
|
|
141
|
+
const defrag = new Defragmenter();
|
|
142
|
+
const msg = '{"text":"你好世界🌍"}';
|
|
143
|
+
const frames = frameMessage(msg, 20);
|
|
144
|
+
|
|
145
|
+
let result: string | null = null;
|
|
146
|
+
for (const frame of frames) {
|
|
147
|
+
result = defrag.push(frame);
|
|
148
|
+
}
|
|
149
|
+
expect(result).toBe(msg);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('handles large messages (> 64KB total length)', () => {
|
|
153
|
+
const defrag = new Defragmenter();
|
|
154
|
+
// total_length field is 2 bytes, so max representable is 65535
|
|
155
|
+
// But the code handles growth beyond that via the safety check
|
|
156
|
+
const msg = 'Z'.repeat(1000);
|
|
157
|
+
const frames = frameMessage(msg, 100);
|
|
158
|
+
|
|
159
|
+
let result: string | null = null;
|
|
160
|
+
for (const frame of frames) {
|
|
161
|
+
result = defrag.push(frame);
|
|
162
|
+
}
|
|
163
|
+
expect(result).toBe(msg);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('reset() clears internal state', () => {
|
|
167
|
+
const defrag = new Defragmenter();
|
|
168
|
+
const longMsg = 'Y'.repeat(100);
|
|
169
|
+
const frames = frameMessage(longMsg, 30);
|
|
170
|
+
|
|
171
|
+
// Push first frame
|
|
172
|
+
defrag.push(frames[0]!);
|
|
173
|
+
defrag.reset();
|
|
174
|
+
|
|
175
|
+
// After reset, pushing a complete single-frame message should work
|
|
176
|
+
const shortMsg = '{"reset":true}';
|
|
177
|
+
const shortFrames = frameMessage(shortMsg);
|
|
178
|
+
expect(defrag.push(shortFrames[0]!)).toBe(shortMsg);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('frame/defrag round-trip preserves exact content for various sizes', () => {
|
|
182
|
+
const defrag = new Defragmenter();
|
|
183
|
+
for (const size of [0, 1, 19, 20, 21, 50, 100, 509, 510, 1000]) {
|
|
184
|
+
defrag.reset();
|
|
185
|
+
// Size 0 frame has no payload text, skip
|
|
186
|
+
if (size === 0) continue;
|
|
187
|
+
const msg = JSON.stringify({ data: 'x'.repeat(size) });
|
|
188
|
+
const frames = frameMessage(msg, 50);
|
|
189
|
+
let result: string | null = null;
|
|
190
|
+
for (const frame of frames) {
|
|
191
|
+
result = defrag.push(frame);
|
|
192
|
+
}
|
|
193
|
+
expect(result).toBe(msg);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLE message framing per WalletPair Protocol Section 19.5.
|
|
3
|
+
*
|
|
4
|
+
* Frame format: [1 byte flags] [2 bytes total_length BE] [payload fragment]
|
|
5
|
+
*
|
|
6
|
+
* flags bit 0: first fragment
|
|
7
|
+
* flags bit 1: last fragment
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const FLAG_FIRST = 0x01;
|
|
11
|
+
const FLAG_LAST = 0x02;
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_FRAME_PAYLOAD = 509;
|
|
14
|
+
export const MIN_FRAME_PAYLOAD = 20;
|
|
15
|
+
|
|
16
|
+
export const BLE_SERVICE_UUID = 'e3a10001-7770-4270-8000-000077700001';
|
|
17
|
+
export const BLE_WRITE_CHAR_UUID = 'e3a10002-7770-4270-8000-000077700001';
|
|
18
|
+
export const BLE_NOTIFY_CHAR_UUID = 'e3a10003-7770-4270-8000-000077700001';
|
|
19
|
+
|
|
20
|
+
/** Split a JSON string into BLE frames. */
|
|
21
|
+
export function frameMessage(
|
|
22
|
+
jsonStr: string,
|
|
23
|
+
maxPayload = DEFAULT_FRAME_PAYLOAD,
|
|
24
|
+
): Uint8Array[] {
|
|
25
|
+
const payload = new TextEncoder().encode(jsonStr);
|
|
26
|
+
const frames: Uint8Array[] = [];
|
|
27
|
+
|
|
28
|
+
if (payload.length === 0) {
|
|
29
|
+
const frame = new Uint8Array(3);
|
|
30
|
+
frame[0] = FLAG_FIRST | FLAG_LAST;
|
|
31
|
+
return [frame];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const chunkSize = Math.max(maxPayload, MIN_FRAME_PAYLOAD);
|
|
35
|
+
|
|
36
|
+
for (let offset = 0; offset < payload.length; offset += chunkSize) {
|
|
37
|
+
const isFirst = offset === 0;
|
|
38
|
+
const end = Math.min(offset + chunkSize, payload.length);
|
|
39
|
+
const isLast = end === payload.length;
|
|
40
|
+
const fragment = payload.subarray(offset, end);
|
|
41
|
+
|
|
42
|
+
const frame = new Uint8Array(3 + fragment.length);
|
|
43
|
+
frame[0] = (isFirst ? FLAG_FIRST : 0) | (isLast ? FLAG_LAST : 0);
|
|
44
|
+
if (isFirst) {
|
|
45
|
+
frame[1] = (payload.length >> 8) & 0xff;
|
|
46
|
+
frame[2] = payload.length & 0xff;
|
|
47
|
+
}
|
|
48
|
+
frame.set(fragment, 3);
|
|
49
|
+
frames.push(frame);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return frames;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Accumulates BLE frames and emits complete JSON strings. */
|
|
56
|
+
export class Defragmenter {
|
|
57
|
+
private buffer: Uint8Array | null = null;
|
|
58
|
+
private offset = 0;
|
|
59
|
+
|
|
60
|
+
push(data: Uint8Array): string | null {
|
|
61
|
+
if (data.length < 3) return null;
|
|
62
|
+
|
|
63
|
+
const flags = data[0]!;
|
|
64
|
+
const isFirst = !!(flags & FLAG_FIRST);
|
|
65
|
+
const isLast = !!(flags & FLAG_LAST);
|
|
66
|
+
const fragment = data.subarray(3);
|
|
67
|
+
|
|
68
|
+
if (isFirst) {
|
|
69
|
+
const totalLength = (data[1]! << 8) | data[2]!;
|
|
70
|
+
this.buffer = new Uint8Array(totalLength || fragment.length);
|
|
71
|
+
this.offset = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.buffer) {
|
|
75
|
+
if (this.offset + fragment.length <= this.buffer.length) {
|
|
76
|
+
this.buffer.set(fragment, this.offset);
|
|
77
|
+
} else {
|
|
78
|
+
const grown = new Uint8Array(this.offset + fragment.length);
|
|
79
|
+
grown.set(this.buffer.subarray(0, this.offset));
|
|
80
|
+
grown.set(fragment, this.offset);
|
|
81
|
+
this.buffer = grown;
|
|
82
|
+
}
|
|
83
|
+
this.offset += fragment.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isLast && this.buffer) {
|
|
87
|
+
const result = new TextDecoder().decode(this.buffer.subarray(0, this.offset));
|
|
88
|
+
this.buffer = null;
|
|
89
|
+
this.offset = 0;
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
reset(): void {
|
|
97
|
+
this.buffer = null;
|
|
98
|
+
this.offset = 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/ble/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLE transport exports.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports framing utilities and provides the Web Bluetooth Central transport.
|
|
5
|
+
* Safe to import on any platform — Web Bluetooth availability is checked at runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
frameMessage,
|
|
10
|
+
Defragmenter,
|
|
11
|
+
DEFAULT_FRAME_PAYLOAD,
|
|
12
|
+
MIN_FRAME_PAYLOAD,
|
|
13
|
+
BLE_SERVICE_UUID,
|
|
14
|
+
BLE_WRITE_CHAR_UUID,
|
|
15
|
+
BLE_NOTIFY_CHAR_UUID,
|
|
16
|
+
} from './framing.js';
|
|
17
|
+
|
|
18
|
+
export { WebBleCentralTransport, isWebBleSupported } from './web-ble-transport.js';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { WebBleCentralTransport, isWebBleSupported } from './web-ble-transport.js';
|
|
3
|
+
|
|
4
|
+
// ── Mock Web Bluetooth API ──────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function createMockCharacteristic() {
|
|
7
|
+
const listeners = new Map<string, Function>();
|
|
8
|
+
return {
|
|
9
|
+
writeValueWithoutResponse: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
startNotifications: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
addEventListener: vi.fn((evt: string, fn: Function) => { listeners.set(evt, fn); }),
|
|
12
|
+
removeEventListener: vi.fn((evt: string) => { listeners.delete(evt); }),
|
|
13
|
+
_fire: (evt: string, data: any) => listeners.get(evt)?.(data),
|
|
14
|
+
value: null as DataView | null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createMockBleDevice() {
|
|
19
|
+
const listeners = new Map<string, Function>();
|
|
20
|
+
const writeChar = createMockCharacteristic();
|
|
21
|
+
const notifyChar = createMockCharacteristic();
|
|
22
|
+
|
|
23
|
+
const service = {
|
|
24
|
+
getCharacteristic: vi.fn((uuid: string) => {
|
|
25
|
+
// BLE_WRITE_CHAR_UUID = 'e3a10002-...'
|
|
26
|
+
if (uuid.includes('10002')) return Promise.resolve(writeChar);
|
|
27
|
+
if (uuid.includes('10003')) return Promise.resolve(notifyChar);
|
|
28
|
+
return Promise.reject(new Error('unknown characteristic'));
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const server = {
|
|
33
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
getPrimaryService: vi.fn().mockResolvedValue(service),
|
|
35
|
+
connected: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Make connect() return the server with getPrimaryService
|
|
39
|
+
server.connect.mockResolvedValue(server);
|
|
40
|
+
|
|
41
|
+
const device = {
|
|
42
|
+
gatt: {
|
|
43
|
+
connect: vi.fn().mockResolvedValue(server),
|
|
44
|
+
connected: true,
|
|
45
|
+
disconnect: vi.fn(),
|
|
46
|
+
},
|
|
47
|
+
addEventListener: vi.fn((evt: string, fn: Function) => { listeners.set(evt, fn); }),
|
|
48
|
+
removeEventListener: vi.fn((evt: string) => { listeners.delete(evt); }),
|
|
49
|
+
_fireDisconnect: () => listeners.get('gattserverdisconnected')?.({} as Event),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return { device, writeChar, notifyChar, server };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockNavigatorBluetooth(device: any) {
|
|
56
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
57
|
+
value: {
|
|
58
|
+
bluetooth: {
|
|
59
|
+
requestDevice: vi.fn().mockResolvedValue(device),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clearNavigatorBluetooth() {
|
|
68
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
69
|
+
value: undefined,
|
|
70
|
+
writable: true,
|
|
71
|
+
configurable: true,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── isWebBleSupported ───────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('isWebBleSupported', () => {
|
|
78
|
+
it('returns false when navigator is undefined', () => {
|
|
79
|
+
clearNavigatorBluetooth();
|
|
80
|
+
expect(isWebBleSupported()).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns false when navigator.bluetooth is missing', () => {
|
|
84
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
85
|
+
value: {},
|
|
86
|
+
writable: true,
|
|
87
|
+
configurable: true,
|
|
88
|
+
});
|
|
89
|
+
expect(isWebBleSupported()).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns true when navigator.bluetooth exists', () => {
|
|
93
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
94
|
+
value: { bluetooth: {} },
|
|
95
|
+
writable: true,
|
|
96
|
+
configurable: true,
|
|
97
|
+
});
|
|
98
|
+
expect(isWebBleSupported()).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── WebBleCentralTransport ──────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe('WebBleCentralTransport', () => {
|
|
105
|
+
let transport: WebBleCentralTransport;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
transport = new WebBleCentralTransport();
|
|
109
|
+
clearNavigatorBluetooth();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('starts in disconnected state', () => {
|
|
113
|
+
expect(transport.state).toBe('disconnected');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('connect() throws when Web Bluetooth is not supported', async () => {
|
|
117
|
+
await expect(transport.connect()).rejects.toThrow('Web Bluetooth is not supported');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('connect() transitions through connecting to connected', async () => {
|
|
121
|
+
const { device } = createMockBleDevice();
|
|
122
|
+
mockNavigatorBluetooth(device);
|
|
123
|
+
|
|
124
|
+
const openHandler = vi.fn();
|
|
125
|
+
transport.onOpen(openHandler);
|
|
126
|
+
|
|
127
|
+
await transport.connect();
|
|
128
|
+
|
|
129
|
+
expect(transport.state).toBe('connected');
|
|
130
|
+
expect(openHandler).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('connect() sets up GATT characteristics', async () => {
|
|
134
|
+
const { device, notifyChar } = createMockBleDevice();
|
|
135
|
+
mockNavigatorBluetooth(device);
|
|
136
|
+
|
|
137
|
+
await transport.connect();
|
|
138
|
+
|
|
139
|
+
expect(notifyChar.startNotifications).toHaveBeenCalled();
|
|
140
|
+
expect(notifyChar.addEventListener).toHaveBeenCalledWith(
|
|
141
|
+
'characteristicvaluechanged',
|
|
142
|
+
expect.any(Function),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('disconnect() calls gatt.disconnect and cleans up', async () => {
|
|
147
|
+
const { device } = createMockBleDevice();
|
|
148
|
+
mockNavigatorBluetooth(device);
|
|
149
|
+
|
|
150
|
+
await transport.connect();
|
|
151
|
+
transport.disconnect();
|
|
152
|
+
|
|
153
|
+
expect(device.gatt.disconnect).toHaveBeenCalled();
|
|
154
|
+
expect(transport.state).toBe('disconnected');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('disconnect() is safe to call when not connected', () => {
|
|
158
|
+
expect(() => transport.disconnect()).not.toThrow();
|
|
159
|
+
expect(transport.state).toBe('disconnected');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('send() does nothing when not connected', () => {
|
|
163
|
+
transport.send({ v: 1, t: 'ping', ch: 'abc' } as any);
|
|
164
|
+
// Should not throw
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('onClose handler fires on GATT disconnect', async () => {
|
|
168
|
+
const { device } = createMockBleDevice();
|
|
169
|
+
mockNavigatorBluetooth(device);
|
|
170
|
+
|
|
171
|
+
const closeHandler = vi.fn();
|
|
172
|
+
transport.onClose(closeHandler);
|
|
173
|
+
|
|
174
|
+
await transport.connect();
|
|
175
|
+
device._fireDisconnect();
|
|
176
|
+
|
|
177
|
+
expect(closeHandler).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(transport.state).toBe('disconnected');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('handler registration methods work', () => {
|
|
182
|
+
const msgHandler = vi.fn();
|
|
183
|
+
const closeHandler = vi.fn();
|
|
184
|
+
const openHandler = vi.fn();
|
|
185
|
+
|
|
186
|
+
transport.onMessage(msgHandler);
|
|
187
|
+
transport.onClose(closeHandler);
|
|
188
|
+
transport.onOpen(openHandler);
|
|
189
|
+
|
|
190
|
+
// No throws — handlers registered
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Bluetooth Central transport for dApp side.
|
|
3
|
+
*
|
|
4
|
+
* The dApp acts as BLE Central and connects to the wallet's GATT Peripheral.
|
|
5
|
+
* Safe to import anywhere — checks Web Bluetooth availability at runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// <reference path="./web-bluetooth.d.ts" />
|
|
9
|
+
|
|
10
|
+
import type { Transport, TransportState, ProtocolMessage } from '../types.js';
|
|
11
|
+
import {
|
|
12
|
+
BLE_SERVICE_UUID,
|
|
13
|
+
BLE_WRITE_CHAR_UUID,
|
|
14
|
+
BLE_NOTIFY_CHAR_UUID,
|
|
15
|
+
frameMessage,
|
|
16
|
+
Defragmenter,
|
|
17
|
+
} from './framing.js';
|
|
18
|
+
|
|
19
|
+
export function isWebBleSupported(): boolean {
|
|
20
|
+
return typeof navigator !== 'undefined' && !!navigator.bluetooth;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WebBleCentralTransport implements Transport {
|
|
24
|
+
state: TransportState = 'disconnected';
|
|
25
|
+
|
|
26
|
+
private device: BluetoothDevice | null = null;
|
|
27
|
+
private writeChar: BluetoothRemoteGATTCharacteristic | null = null;
|
|
28
|
+
private notifyChar: BluetoothRemoteGATTCharacteristic | null = null;
|
|
29
|
+
private defrag = new Defragmenter();
|
|
30
|
+
private mtuPayload = 509;
|
|
31
|
+
|
|
32
|
+
private messageHandler: ((msg: ProtocolMessage) => void) | null = null;
|
|
33
|
+
private closeHandler: (() => void) | null = null;
|
|
34
|
+
private openHandler: (() => void) | null = null;
|
|
35
|
+
|
|
36
|
+
onMessage(handler: (msg: ProtocolMessage) => void): void { this.messageHandler = handler; }
|
|
37
|
+
onClose(handler: () => void): void { this.closeHandler = handler; }
|
|
38
|
+
onOpen(handler: () => void): void { this.openHandler = handler; }
|
|
39
|
+
|
|
40
|
+
async connect(): Promise<void> {
|
|
41
|
+
if (!isWebBleSupported()) {
|
|
42
|
+
throw new Error('Web Bluetooth is not supported in this environment');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.state = 'connecting';
|
|
46
|
+
|
|
47
|
+
const device = await navigator.bluetooth!.requestDevice({
|
|
48
|
+
filters: [
|
|
49
|
+
{ namePrefix: 'WalletPair' },
|
|
50
|
+
{ services: [BLE_SERVICE_UUID] },
|
|
51
|
+
],
|
|
52
|
+
optionalServices: [BLE_SERVICE_UUID],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.device = device;
|
|
56
|
+
device.addEventListener('gattserverdisconnected', this.onDisconnect);
|
|
57
|
+
|
|
58
|
+
const server = await device.gatt!.connect();
|
|
59
|
+
const service = await server.getPrimaryService(BLE_SERVICE_UUID);
|
|
60
|
+
|
|
61
|
+
this.writeChar = await service.getCharacteristic(BLE_WRITE_CHAR_UUID);
|
|
62
|
+
this.notifyChar = await service.getCharacteristic(BLE_NOTIFY_CHAR_UUID);
|
|
63
|
+
|
|
64
|
+
await this.notifyChar.startNotifications();
|
|
65
|
+
this.notifyChar.addEventListener('characteristicvaluechanged', this.onNotification);
|
|
66
|
+
|
|
67
|
+
this.state = 'connected';
|
|
68
|
+
this.openHandler?.();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
send(msg: ProtocolMessage): void {
|
|
72
|
+
if (!this.writeChar || this.state !== 'connected') return;
|
|
73
|
+
const frames = frameMessage(JSON.stringify(msg), this.mtuPayload);
|
|
74
|
+
// Send frames sequentially
|
|
75
|
+
let chain = Promise.resolve();
|
|
76
|
+
for (const frame of frames) {
|
|
77
|
+
chain = chain.then(() => this.writeChar!.writeValueWithoutResponse(frame as unknown as ArrayBuffer));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
disconnect(): void {
|
|
82
|
+
if (this.device?.gatt?.connected) {
|
|
83
|
+
this.device.gatt.disconnect();
|
|
84
|
+
}
|
|
85
|
+
this.cleanup();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private onNotification = (event: Event): void => {
|
|
89
|
+
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
|
90
|
+
const dv = target.value!;
|
|
91
|
+
const data = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
|
92
|
+
const json = this.defrag.push(data);
|
|
93
|
+
if (json && this.messageHandler) {
|
|
94
|
+
try { this.messageHandler(JSON.parse(json)); } catch { /* bad json */ }
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
private onDisconnect = (): void => {
|
|
99
|
+
this.cleanup();
|
|
100
|
+
this.closeHandler?.();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
private cleanup(): void {
|
|
104
|
+
if (this.notifyChar) {
|
|
105
|
+
this.notifyChar.removeEventListener('characteristicvaluechanged', this.onNotification);
|
|
106
|
+
}
|
|
107
|
+
if (this.device) {
|
|
108
|
+
this.device.removeEventListener('gattserverdisconnected', this.onDisconnect);
|
|
109
|
+
}
|
|
110
|
+
this.device = null;
|
|
111
|
+
this.writeChar = null;
|
|
112
|
+
this.notifyChar = null;
|
|
113
|
+
this.defrag.reset();
|
|
114
|
+
this.state = 'disconnected';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient type declarations for the Web Bluetooth API.
|
|
3
|
+
* Avoids requiring @types/web-bluetooth as a dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface BluetoothDevice extends EventTarget {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly name?: string | undefined;
|
|
9
|
+
readonly gatt?: BluetoothRemoteGATTServer | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BluetoothRemoteGATTServer {
|
|
13
|
+
readonly device: BluetoothDevice;
|
|
14
|
+
readonly connected: boolean;
|
|
15
|
+
connect(): Promise<BluetoothRemoteGATTServer>;
|
|
16
|
+
disconnect(): void;
|
|
17
|
+
getPrimaryService(service: string): Promise<BluetoothRemoteGATTService>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface BluetoothRemoteGATTService {
|
|
21
|
+
getCharacteristic(characteristic: string): Promise<BluetoothRemoteGATTCharacteristic>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
|
|
25
|
+
readonly value: DataView | null;
|
|
26
|
+
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
|
27
|
+
stopNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
|
28
|
+
writeValueWithoutResponse(value: BufferSource): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BluetoothRequestDeviceFilter {
|
|
32
|
+
namePrefix?: string;
|
|
33
|
+
services?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RequestDeviceOptions {
|
|
37
|
+
filters?: BluetoothRequestDeviceFilter[];
|
|
38
|
+
optionalServices?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Bluetooth {
|
|
42
|
+
requestDevice(options: RequestDeviceOptions): Promise<BluetoothDevice>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Navigator {
|
|
46
|
+
bluetooth?: Bluetooth | undefined;
|
|
47
|
+
}
|