windkit 0.2.1 → 0.2.3

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.
@@ -0,0 +1,375 @@
1
+ // windkit/WalletSession.js
2
+ // Ultra Clean++ (single replace)
3
+ // ✅ fixes peerID case bug in ACTIVE_ACCOUNT_CHANGED
4
+ // ✅ bounded ping heartbeat (jitter) for low-end
5
+ // ✅ strict request mapping + timeout cleanup
6
+ // ✅ minimal surface: transact/signRequest/signMessage/sharedSecret
7
+
8
+ import { Base64u, IdentityProof, SigningRequest } from "@wharfkit/signing-request";
9
+ import {
10
+ Checksum512,
11
+ Name,
12
+ PermissionLevel,
13
+ PublicKey,
14
+ Serializer,
15
+ Signature,
16
+ SignedTransaction,
17
+ } from "@wharfkit/antelope";
18
+
19
+ import zlib from "pako";
20
+ import { loadSession, saveSession } from "./StoreSession.js";
21
+
22
+ const DEFAULT_TIMEOUT_MS = 90_000;
23
+ const PING_MS = 15_000;
24
+ const PING_JITTER_MS = 2_000;
25
+
26
+ function uuid() {
27
+ try {
28
+ if (globalThis?.crypto?.randomUUID) return globalThis.crypto.randomUUID();
29
+ } catch {}
30
+ return `id-${Math.random().toString(16).slice(2)}-${Date.now().toString(16)}`;
31
+ }
32
+
33
+ function setTimer(fn, ms) {
34
+ try {
35
+ return globalThis.setTimeout(fn, ms);
36
+ } catch {
37
+ return setTimeout(fn, ms);
38
+ }
39
+ }
40
+
41
+ function clearTimer(id) {
42
+ try {
43
+ globalThis.clearTimeout(id);
44
+ } catch {
45
+ clearTimeout(id);
46
+ }
47
+ }
48
+
49
+ function normalizeTimeoutMs(v, fallback = DEFAULT_TIMEOUT_MS) {
50
+ const n = Number(v);
51
+ if (!Number.isFinite(n)) return fallback;
52
+ return Math.max(1000, Math.trunc(n));
53
+ }
54
+
55
+ function errorFromReply(reply, fallback) {
56
+ const err = reply?.error;
57
+ if (typeof err === "string") return new Error(err);
58
+ if (err?.message) return new Error(err.message);
59
+ return new Error(fallback || "Request rejected.");
60
+ }
61
+
62
+ /**
63
+ * WalletSession
64
+ * - Wraps a PeerJS DataConnection (DApp->Wallet)
65
+ * - Sends requests to wallet and maps replies by id
66
+ */
67
+ export class WalletSession {
68
+ static ChainID = "f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f";
69
+
70
+ /** @type {import("peerjs").DataConnection} */
71
+ #connection;
72
+
73
+ /** @type {Map<string, {resolve:Function, reject:Function, timeout:any}>} */
74
+ #pending = new Map();
75
+
76
+ /** @type {{zlib:any, abiProvider?:any} | undefined} */
77
+ #encodingOptions;
78
+
79
+ /** @type {PermissionLevel | undefined} */
80
+ #permissionLevel;
81
+
82
+ /** @type {(permission: PermissionLevel) => void | undefined} */
83
+ #accountChangeListener;
84
+
85
+ /** @type {() => void | undefined} */
86
+ #closeListener;
87
+
88
+ /** @type {(error: Error) => void | undefined} */
89
+ #errorListener;
90
+
91
+ /** @type {any} */
92
+ #pingTimer = null;
93
+
94
+ /**
95
+ * @param {import("peerjs").DataConnection} connection
96
+ */
97
+ constructor(connection) {
98
+ this.#connection = connection;
99
+
100
+ connection.on("data", (msg) => this.#onDataReceived(msg));
101
+
102
+ connection.on("close", () => {
103
+ this.#stopPing();
104
+
105
+ for (const [id, p] of this.#pending.entries()) {
106
+ try {
107
+ clearTimer(p.timeout);
108
+ } catch {}
109
+ try {
110
+ p.reject(new Error("Wallet connection closed."));
111
+ } catch {}
112
+ this.#pending.delete(id);
113
+ }
114
+
115
+ if (this.#closeListener) this.#closeListener();
116
+ });
117
+
118
+ connection.on("error", (error) => {
119
+ if (this.#errorListener) this.#errorListener(error);
120
+ });
121
+
122
+ this.#startPing();
123
+ }
124
+
125
+ /**
126
+ * Optional ABI cache provider (recommended when creating requests).
127
+ * @param {import("@wharfkit/abicache").ABICache} cache
128
+ */
129
+ setABICache(cache) {
130
+ this.#encodingOptions = { zlib, abiProvider: cache };
131
+ }
132
+
133
+ onAccountChange(listener) {
134
+ this.#accountChangeListener = listener;
135
+ }
136
+
137
+ onClose(listener) {
138
+ this.#closeListener = listener;
139
+ }
140
+
141
+ onError(listener) {
142
+ this.#errorListener = listener;
143
+ }
144
+
145
+ isOpen() {
146
+ return Boolean(this.#connection?.open);
147
+ }
148
+
149
+ close() {
150
+ this.#stopPing();
151
+ try {
152
+ this.#connection.close();
153
+ } catch {}
154
+ }
155
+
156
+ metadata() {
157
+ return this.#connection.metadata;
158
+ }
159
+
160
+ get permissionLevel() {
161
+ return this.#permissionLevel;
162
+ }
163
+
164
+ set permissionLevel(value) {
165
+ this.#permissionLevel = value;
166
+ }
167
+
168
+ get actor() {
169
+ return this.#permissionLevel?.actor ?? Name.from("");
170
+ }
171
+
172
+ get permission() {
173
+ return this.#permissionLevel?.permission ?? Name.from("");
174
+ }
175
+
176
+ /**
177
+ * @typedef {Object} TransactArguments
178
+ * @property {import("@wharfkit/antelope").Action=} action
179
+ * @property {Array<import("@wharfkit/antelope").Action>=} actions
180
+ * @property {import("@wharfkit/antelope").Transaction=} transaction
181
+ */
182
+
183
+ /**
184
+ * @typedef {Object} TransactOptions
185
+ * @property {boolean=} broadcast True to broadcast, false for sign-only.
186
+ * @property {number=} timeoutMs Per-request timeout (default 90s).
187
+ */
188
+
189
+ /**
190
+ * Create a VSR signing request for a transaction and send to wallet.
191
+ * @param {TransactArguments} args
192
+ * @param {TransactOptions=} options
193
+ * @returns {Promise<SignedTransaction|any>} SignedTransaction (sign-only) OR wallet push result (broadcast)
194
+ */
195
+ async transact(args, options) {
196
+ const willBroadcast = typeof options?.broadcast === "boolean" ? options.broadcast : true;
197
+
198
+ const requestArgs = { ...args, chainId: WalletSession.ChainID };
199
+ const req = await SigningRequest.create(requestArgs, this.#encodingOptions);
200
+ req.setBroadcast(willBroadcast);
201
+
202
+ const vsr = req.encode(true, false, "vsr:");
203
+ return this.signRequest(vsr, options?.timeoutMs);
204
+ }
205
+
206
+ /**
207
+ * Send a signing request string to the wallet.
208
+ * @param {string} vsr
209
+ * @param {number=} timeoutMs
210
+ * @returns {Promise<any|SignedTransaction>}
211
+ */
212
+ signRequest(vsr, timeoutMs) {
213
+ return this.#request("signRequest", { vsr }, timeoutMs, (reply) => {
214
+ if (reply?.code === "SENT") return reply.result;
215
+ if (reply?.code === "SIGNED") return SignedTransaction.from(reply.result);
216
+ throw errorFromReply(reply, "Signing request rejected.");
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Ask wallet to sign an arbitrary message.
222
+ * @param {string} message
223
+ * @param {number=} timeoutMs
224
+ * @returns {Promise<Signature>}
225
+ */
226
+ signMessage(message, timeoutMs) {
227
+ return this.#request("signMessage", { message }, timeoutMs, (reply) => {
228
+ if (reply?.code === "SIGNED") return Signature.from(reply.result.signature);
229
+ throw errorFromReply(reply, "Message signing rejected.");
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Ask wallet to derive a shared secret from a public key.
235
+ * @param {PublicKey} publicKey
236
+ * @param {number=} timeoutMs
237
+ * @returns {Promise<Checksum512>}
238
+ */
239
+ sharedSecret(publicKey, timeoutMs) {
240
+ return this.#request("sharedSecret", { key: publicKey.toString() }, timeoutMs, (reply) => {
241
+ if (reply?.code === "CREATED") return Checksum512.from(reply.result.secret);
242
+ throw errorFromReply(reply, "Shared secret creation failed.");
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Internal: handle ACTIVE_ACCOUNT_CHANGED.
248
+ * @param {string} auth
249
+ */
250
+ #onChangeAccount(auth) {
251
+ const proof = Serializer.decode({ data: Base64u.decode(auth), type: IdentityProof });
252
+ this.permissionLevel = proof.signer;
253
+
254
+ const session = loadSession();
255
+ if (session) {
256
+ // ✅ FIX: property is peerID (case-sensitive)
257
+ saveSession({
258
+ peerID: session.peerID,
259
+ permission: proof.signer.toString(),
260
+ expiration: proof.expiration,
261
+ auth,
262
+ });
263
+ }
264
+
265
+ if (this.#accountChangeListener) this.#accountChangeListener(proof.signer);
266
+ }
267
+
268
+ #startPing() {
269
+ if (this.#pingTimer) return;
270
+
271
+ const loop = () => {
272
+ if (!this.isOpen()) return;
273
+
274
+ const jitter = Math.floor(Math.random() * PING_JITTER_MS);
275
+ this.#pingTimer = setTimer(() => {
276
+ this.#ping().catch(() => {});
277
+ loop();
278
+ }, PING_MS + jitter);
279
+ };
280
+
281
+ loop();
282
+ }
283
+
284
+ #stopPing() {
285
+ if (this.#pingTimer != null) {
286
+ clearTimer(this.#pingTimer);
287
+ this.#pingTimer = null;
288
+ }
289
+ }
290
+
291
+ async #ping() {
292
+ if (!this.isOpen()) return;
293
+ try {
294
+ this.#connection.send({ method: "ping", id: uuid(), params: { time: Date.now() } });
295
+ } catch {
296
+ // best-effort only
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Core request helper with timeout + reply mapping.
302
+ * @template T
303
+ * @param {string} method
304
+ * @param {any} params
305
+ * @param {number=} timeoutMs
306
+ * @param {(reply:any)=>T} mapper
307
+ * @returns {Promise<T>}
308
+ */
309
+ #request(method, params, timeoutMs, mapper) {
310
+ if (!this.isOpen()) return Promise.reject(new Error("Wallet connection is not open."));
311
+
312
+ const id = uuid();
313
+ const ms = normalizeTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS);
314
+
315
+ return new Promise((resolve, reject) => {
316
+ const timeout = setTimer(() => {
317
+ this.#pending.delete(id);
318
+ reject(new Error(`${method} timed out.`));
319
+ }, ms);
320
+
321
+ this.#pending.set(id, {
322
+ timeout,
323
+ resolve: (reply) => {
324
+ try {
325
+ const out = mapper(reply);
326
+ clearTimer(timeout);
327
+ this.#pending.delete(id);
328
+ resolve(out);
329
+ } catch (e) {
330
+ clearTimer(timeout);
331
+ this.#pending.delete(id);
332
+ reject(e);
333
+ }
334
+ },
335
+ reject: (e) => {
336
+ clearTimer(timeout);
337
+ this.#pending.delete(id);
338
+ reject(e);
339
+ },
340
+ });
341
+
342
+ try {
343
+ this.#connection.send({ method, id, params });
344
+ } catch (e) {
345
+ clearTimer(timeout);
346
+ this.#pending.delete(id);
347
+ reject(e);
348
+ }
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Handle incoming messages from wallet.
354
+ * @param {any} data
355
+ */
356
+ #onDataReceived(data) {
357
+ if (!data || typeof data !== "object") return;
358
+
359
+ // Reply to pending request
360
+ if (data.id && this.#pending.has(data.id)) {
361
+ const p = this.#pending.get(data.id);
362
+ try {
363
+ p.resolve(data);
364
+ } catch (e) {
365
+ p.reject(e);
366
+ }
367
+ return;
368
+ }
369
+
370
+ // Wallet push notifications
371
+ if (data.code === "ACTIVE_ACCOUNT_CHANGED" && data?.result?.auth) {
372
+ this.#onChangeAccount(data.result.auth);
373
+ }
374
+ }
375
+ }
@@ -0,0 +1,294 @@
1
+ // windkit/WindConnector.js
2
+ // Ultra Clean++ (single replace)
3
+ // ✅ PeerID embedded into VSR (infoKey "pi")
4
+ // ✅ connect() reuses stored peerID (stable pairing)
5
+ // ✅ LOGIN_OK → decode IdentityProof → yield WalletSession
6
+ // ✅ low-end friendly: minimal work, no polling loops
7
+
8
+ import { Base64u, IdentityProof, SigningRequest } from "@wharfkit/signing-request";
9
+ import { Serializer } from "@wharfkit/antelope";
10
+ import Peer from "peerjs";
11
+ import zlib from "pako";
12
+
13
+ import { WalletSession } from "./WalletSession.js";
14
+ import { loadSession, saveSession, clearSession } from "./StoreSession.js";
15
+
16
+ function uuid() {
17
+ try {
18
+ if (globalThis?.crypto?.randomUUID) return globalThis.crypto.randomUUID();
19
+ } catch {}
20
+ return `id-${Math.random().toString(16).slice(2)}-${Date.now().toString(16)}`;
21
+ }
22
+
23
+ function originSafe() {
24
+ try {
25
+ return globalThis?.location?.origin || "";
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ function defaultPeerConfig() {
32
+ return {
33
+ iceServers: [
34
+ { urls: "stun:stun.l.google.com:19302" },
35
+ { urls: "stun:stun1.l.google.com:3478" },
36
+ { urls: "stun:stun.relay.metered.ca:80" },
37
+ {
38
+ urls: "turn:asia.relay.metered.ca:80",
39
+ username: "b66cd40a117bddb5cde924ab",
40
+ credential: "4jRmuTehVCZ2a/S+",
41
+ },
42
+ ],
43
+ sdpSemantics: "unified-plan",
44
+ };
45
+ }
46
+
47
+ function mergePeerOptions(userOptions) {
48
+ const opts = userOptions ? { ...userOptions } : {};
49
+ const base = defaultPeerConfig();
50
+
51
+ // Keep base config but allow user overrides
52
+ if (!opts.config) {
53
+ opts.config = base;
54
+ return opts;
55
+ }
56
+
57
+ const userCfg = { ...opts.config };
58
+ const userIce = Array.isArray(userCfg.iceServers) ? userCfg.iceServers : null;
59
+
60
+ opts.config = {
61
+ ...base,
62
+ ...userCfg,
63
+ iceServers: userIce ? [...base.iceServers, ...userIce] : base.iceServers,
64
+ };
65
+
66
+ return opts;
67
+ }
68
+
69
+ /**
70
+ * WindConnector (DApp-side)
71
+ * - Owns PeerJS instance (DApp is server peer)
72
+ * - Creates VSR identity login request
73
+ * - Waits for wallet connection + LOGIN_OK, then yields WalletSession
74
+ */
75
+ export class WindConnector {
76
+ /** @type {Peer | null} */
77
+ #peer = null;
78
+
79
+ /** @type {import("peerjs").PeerJSOption} */
80
+ #peerOptions;
81
+
82
+ /** @type {string | null} */
83
+ #peerId = null;
84
+
85
+ /** @type {Map<string, Function>} */
86
+ #listeners = new Map();
87
+
88
+ /** @type {any} */
89
+ #identityArgs;
90
+
91
+ /**
92
+ * @param {import("peerjs").PeerJSOption=} options Optional PeerJS options.
93
+ */
94
+ constructor(options) {
95
+ this.#peerOptions = mergePeerOptions(options);
96
+
97
+ this.#identityArgs = {
98
+ scope: "vexanium",
99
+ chainId: WalletSession.ChainID,
100
+ callback: originSafe(),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Add extra STUN/TURN server.
106
+ * @param {RTCIceServer} server
107
+ */
108
+ addIceServer(server) {
109
+ if (!this.#peerOptions.config) this.#peerOptions.config = { iceServers: [] };
110
+ if (!Array.isArray(this.#peerOptions.config.iceServers)) this.#peerOptions.config.iceServers = [];
111
+ this.#peerOptions.config.iceServers.push(server);
112
+ }
113
+
114
+ /**
115
+ * Set PeerJS signaling server (optional).
116
+ * @param {string} host
117
+ * @param {number=} port
118
+ * @param {string=} path
119
+ * @param {boolean=} secure
120
+ */
121
+ setServer(host, port, path, secure) {
122
+ this.#peerOptions.host = host;
123
+ if (typeof port === "number") this.#peerOptions.port = port;
124
+ if (typeof path === "string") this.#peerOptions.path = path;
125
+ if (typeof secure === "boolean") this.#peerOptions.secure = secure;
126
+ }
127
+
128
+ /**
129
+ * Subscribe to events.
130
+ * Supported: "open", "close", "disconnected", "error", "connection", "session"
131
+ * @param {string} event
132
+ * @param {Function} func
133
+ */
134
+ on(event, func) {
135
+ this.#listeners.set(String(event || ""), func);
136
+ }
137
+
138
+ /**
139
+ * Unsubscribe.
140
+ * @param {string} event
141
+ */
142
+ off(event) {
143
+ this.#listeners.delete(String(event || ""));
144
+ }
145
+
146
+ /**
147
+ * Connect to signaling server. Resolves when Peer is "open".
148
+ * @returns {Promise<string>} peerId
149
+ */
150
+ async connect() {
151
+ if (this.#peer && !this.#peer.destroyed) return this.#peer.id;
152
+
153
+ const stored = loadSession();
154
+ this.#peerId = stored?.peerID || this.#peerId || `VEX-${uuid()}`;
155
+
156
+ const peer = new Peer(this.#peerId, this.#peerOptions);
157
+ this.#peer = peer;
158
+
159
+ peer.on("connection", (conn) => this.#onConnection(conn));
160
+
161
+ // Bridge PeerJS events to our listeners (except "session" which is internal)
162
+ for (const [key, fn] of this.#listeners.entries()) {
163
+ if (key === "session") continue;
164
+ try {
165
+ peer.on(key, fn);
166
+ } catch {}
167
+ }
168
+
169
+ return await new Promise((resolve, reject) => {
170
+ const onOpen = (id) => {
171
+ try {
172
+ peer.off("error", onError);
173
+ } catch {}
174
+ resolve(id);
175
+ };
176
+ const onError = (err) => {
177
+ try {
178
+ peer.off("open", onOpen);
179
+ } catch {}
180
+ reject(err);
181
+ };
182
+ peer.once("open", onOpen);
183
+ peer.once("error", onError);
184
+ });
185
+ }
186
+
187
+ disconnect() {
188
+ try {
189
+ this.#peer?.disconnect();
190
+ } catch {}
191
+ }
192
+
193
+ destroy() {
194
+ try {
195
+ this.#peer?.destroy();
196
+ } catch {}
197
+ this.#peer = null;
198
+ }
199
+
200
+ reconnect() {
201
+ try {
202
+ this.#peer?.reconnect();
203
+ } catch {}
204
+ }
205
+
206
+ isDisconnected() {
207
+ return this.#peer ? this.#peer.disconnected : true;
208
+ }
209
+
210
+ isDestroyed() {
211
+ return this.#peer ? this.#peer.destroyed : true;
212
+ }
213
+
214
+ /**
215
+ * Create VSR identity request for login.
216
+ * Put this string into:
217
+ * - QR payload, or
218
+ * - wallet login URL query (?vsr=...)
219
+ *
220
+ * @param {string} name App name
221
+ * @param {string=} icon App icon URL
222
+ * @returns {string} "vsr:...."
223
+ */
224
+ createLoginRequest(name, icon) {
225
+ const session = loadSession();
226
+
227
+ if (session) {
228
+ // reuse peer id + identity hints
229
+ this.#peerId = session.peerID;
230
+
231
+ const [actor, perm] = String(session.permission).split("@");
232
+ this.#identityArgs.account = actor;
233
+ this.#identityArgs.permission = perm;
234
+ } else {
235
+ clearSession();
236
+ this.#peerId = `VEX-${uuid()}`;
237
+ delete this.#identityArgs.account;
238
+ delete this.#identityArgs.permission;
239
+ }
240
+
241
+ const req = SigningRequest.identity(this.#identityArgs, { zlib });
242
+
243
+ // Compact app info keys
244
+ req.setInfoKey("pi", this.#peerId); // peer id
245
+ req.setInfoKey("na", String(name || "")); // app name
246
+ if (icon) req.setInfoKey("ic", String(icon)); // icon
247
+ req.setInfoKey("do", originSafe()); // dapp origin
248
+
249
+ // If a stored auth proof exists, pass it (wallet may optimize)
250
+ if (session?.auth) req.setInfoKey("auth", session.auth);
251
+
252
+ return req.encode(true, false, "vsr:");
253
+ }
254
+
255
+ /**
256
+ * @param {import("peerjs").DataConnection} conn
257
+ */
258
+ #onConnection(conn) {
259
+ const onConn = this.#listeners.get("connection");
260
+ if (onConn) onConn(conn);
261
+
262
+ // Expect LOGIN_OK first (one-shot)
263
+ conn.once("data", (payload) => {
264
+ if (!payload || typeof payload !== "object") return;
265
+ if (payload.code !== "LOGIN_OK") return;
266
+
267
+ try {
268
+ const auth = payload?.result?.auth;
269
+ if (!auth || typeof auth !== "string") return;
270
+
271
+ const proof = Serializer.decode({
272
+ data: Base64u.decode(auth),
273
+ type: IdentityProof,
274
+ });
275
+
276
+ const session = new WalletSession(conn);
277
+ session.permissionLevel = proof.signer;
278
+
279
+ saveSession({
280
+ peerID: this.#peerId || conn.peer,
281
+ permission: proof.signer.toString(),
282
+ expiration: proof.expiration,
283
+ auth,
284
+ });
285
+
286
+ const onSession = this.#listeners.get("session");
287
+ if (onSession) onSession(session, proof);
288
+ } catch (e) {
289
+ const onError = this.#listeners.get("error");
290
+ if (onError) onError(e);
291
+ }
292
+ });
293
+ }
294
+ }