phantasma-sdk-ts 0.9.2 → 0.10.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 +6 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/link/index.js +5 -0
- package/dist/cjs/link/v5/capabilities.js +6 -0
- package/dist/cjs/link/v5/client.js +324 -0
- package/dist/cjs/link/v5/deeplink.js +123 -0
- package/dist/cjs/link/v5/encoding.js +95 -0
- package/dist/cjs/link/v5/envelope.js +73 -0
- package/dist/cjs/link/v5/errors.js +60 -0
- package/dist/cjs/link/v5/index.js +33 -0
- package/dist/cjs/link/v5/loopback-transport.js +70 -0
- package/dist/cjs/link/v5/methods.js +4 -0
- package/dist/cjs/link/v5/pairing.js +95 -0
- package/dist/cjs/link/v5/protocol.js +61 -0
- package/dist/cjs/link/v5/relay-transport.js +303 -0
- package/dist/cjs/link/v5/session-crypto.js +120 -0
- package/dist/cjs/link/v5/transport.js +208 -0
- package/dist/cjs/link/v5/web-deeplink.js +141 -0
- package/dist/cjs/public.js +37 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/link/index.js +5 -0
- package/dist/esm/link/v5/capabilities.js +5 -0
- package/dist/esm/link/v5/client.js +320 -0
- package/dist/esm/link/v5/deeplink.js +115 -0
- package/dist/esm/link/v5/encoding.js +87 -0
- package/dist/esm/link/v5/envelope.js +65 -0
- package/dist/esm/link/v5/errors.js +56 -0
- package/dist/esm/link/v5/index.js +17 -0
- package/dist/esm/link/v5/loopback-transport.js +66 -0
- package/dist/esm/link/v5/methods.js +3 -0
- package/dist/esm/link/v5/pairing.js +91 -0
- package/dist/esm/link/v5/protocol.js +58 -0
- package/dist/esm/link/v5/relay-transport.js +299 -0
- package/dist/esm/link/v5/session-crypto.js +104 -0
- package/dist/esm/link/v5/transport.js +204 -0
- package/dist/esm/link/v5/web-deeplink.js +133 -0
- package/dist/esm/public.js +3 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/link/v5/capabilities.d.ts +80 -0
- package/dist/types/link/v5/capabilities.d.ts.map +1 -0
- package/dist/types/link/v5/client.d.ts +119 -0
- package/dist/types/link/v5/client.d.ts.map +1 -0
- package/dist/types/link/v5/deeplink.d.ts +52 -0
- package/dist/types/link/v5/deeplink.d.ts.map +1 -0
- package/dist/types/link/v5/encoding.d.ts +15 -0
- package/dist/types/link/v5/encoding.d.ts.map +1 -0
- package/dist/types/link/v5/envelope.d.ts +48 -0
- package/dist/types/link/v5/envelope.d.ts.map +1 -0
- package/dist/types/link/v5/errors.d.ts +39 -0
- package/dist/types/link/v5/errors.d.ts.map +1 -0
- package/dist/types/link/v5/index.d.ts +15 -0
- package/dist/types/link/v5/index.d.ts.map +1 -0
- package/dist/types/link/v5/loopback-transport.d.ts +43 -0
- package/dist/types/link/v5/loopback-transport.d.ts.map +1 -0
- package/dist/types/link/v5/methods.d.ts +83 -0
- package/dist/types/link/v5/methods.d.ts.map +1 -0
- package/dist/types/link/v5/pairing.d.ts +37 -0
- package/dist/types/link/v5/pairing.d.ts.map +1 -0
- package/dist/types/link/v5/protocol.d.ts +60 -0
- package/dist/types/link/v5/protocol.d.ts.map +1 -0
- package/dist/types/link/v5/relay-transport.d.ts +73 -0
- package/dist/types/link/v5/relay-transport.d.ts.map +1 -0
- package/dist/types/link/v5/session-crypto.d.ts +51 -0
- package/dist/types/link/v5/session-crypto.d.ts.map +1 -0
- package/dist/types/link/v5/transport.d.ts +77 -0
- package/dist/types/link/v5/transport.d.ts.map +1 -0
- package/dist/types/link/v5/web-deeplink.d.ts +77 -0
- package/dist/types/link/v5/web-deeplink.d.ts.map +1 -0
- package/dist/types/public.d.ts +1 -0
- package/dist/types/public.d.ts.map +1 -1
- package/examples/web-deeplink-dapp.ts +53 -0
- package/package.json +8 -2
- package/spec/CHANGELOG.md +9 -0
- package/spec/phantasma-link-v5.md +701 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Phantasma Link v5 - the cohesive client (spec §6/§9). This IS the v5 entry point: there is
|
|
2
|
+
// NO separate "EasyConnect"-style wrapper. Transport selection, the connect handshake, and the
|
|
3
|
+
// typed `pha_*` methods are all part of this one client. Build it directly with any transport,
|
|
4
|
+
// or use the `loopback()` factory for the desktop case.
|
|
5
|
+
import { LinkMethod, LinkEvent } from './protocol.js';
|
|
6
|
+
import { LinkSessionClient, } from './transport.js';
|
|
7
|
+
import { LoopbackTransport } from './loopback-transport.js';
|
|
8
|
+
import { DeeplinkTransport, DEEPLINK_REQUEST_TIMEOUT_MS, } from './deeplink.js';
|
|
9
|
+
import { RelayTransport, DEFAULT_RELAY_URL } from './relay-transport.js';
|
|
10
|
+
import { LinkError, LinkErrorCode } from './errors.js';
|
|
11
|
+
import { buildPairingUri } from './pairing.js';
|
|
12
|
+
import { base64UrlToBytes } from './encoding.js';
|
|
13
|
+
import { resolveWebDeeplinkHooks, loadWebDeeplinkRecord, saveWebDeeplinkRecord, createWebDeeplinkRecord, stripUrlFragment, } from './web-deeplink.js';
|
|
14
|
+
import { deriveSessionKey, generateEphemeralKeyPair, randomToken, } from './session-crypto.js';
|
|
15
|
+
/**
|
|
16
|
+
* The v5 client. `connect()` runs the capability handshake and stores the session; the typed
|
|
17
|
+
* methods then forward to the wallet and return validated-by-contract results. Events
|
|
18
|
+
* (account/chain/session changes) are delivered via {@link onEvent}.
|
|
19
|
+
*/
|
|
20
|
+
export class PhantasmaLink5 {
|
|
21
|
+
constructor(transport, options = {}) {
|
|
22
|
+
// Cleanup callbacks registered by factories (e.g. the web-deeplink URL listener);
|
|
23
|
+
// run once on close() so a discarded client does not keep page hooks alive.
|
|
24
|
+
this.disposers = [];
|
|
25
|
+
this.transport = transport;
|
|
26
|
+
// Capture responses that arrive with no in-flight request (the deeplink page reloaded
|
|
27
|
+
// mid-flow). The web-deeplink factory delivers any such response during construction, so a
|
|
28
|
+
// caller can recover it via takeUnmatchedResponse() right after building the client.
|
|
29
|
+
this.session = new LinkSessionClient(transport, {
|
|
30
|
+
...options,
|
|
31
|
+
onUnmatchedResponse: (response) => {
|
|
32
|
+
this.lastUnmatched = response;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
this.defaultDapp = options.dapp;
|
|
36
|
+
this.onSessionChange = options.onSessionChange;
|
|
37
|
+
// One-tap pairing (spec §15 step 3): the wallet may push the connect result as an
|
|
38
|
+
// unsolicited event right after the pairing approval, instead of waiting for an
|
|
39
|
+
// explicit pha_connect. Adopt it exactly like a connect result so the session is
|
|
40
|
+
// live (and persisted via onSessionChange) the moment the event arrives.
|
|
41
|
+
this.session.onEvent((event, data) => {
|
|
42
|
+
if (event === LinkEvent.SessionEstablished && isConnectResult(data)) {
|
|
43
|
+
this.adoptConnectResult(data);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Build a client over the desktop loopback transport (plaintext, trusted-local). */
|
|
48
|
+
static loopback(options = {}) {
|
|
49
|
+
return new PhantasmaLink5(new LoopbackTransport(options));
|
|
50
|
+
}
|
|
51
|
+
/** Build a client over the deeplink transport (spec §17). The channel key from pairing is
|
|
52
|
+
* MANDATORY here: deeplink URLs are interceptable, so plaintext frames are never allowed. */
|
|
53
|
+
static deeplink(options) {
|
|
54
|
+
if (!options.sessionKey || options.sessionKey.length !== 32) {
|
|
55
|
+
throw new LinkError(LinkErrorCode.InvalidParams, 'Deeplink requires the 32-byte pairing session key');
|
|
56
|
+
}
|
|
57
|
+
return new PhantasmaLink5(new DeeplinkTransport(options), {
|
|
58
|
+
sessionKey: options.sessionKey,
|
|
59
|
+
// Deeplink round-trips include an app switch + human consent; see the constant.
|
|
60
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEEPLINK_REQUEST_TIMEOUT_MS,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Build a client over the relay transport (spec §6.4/§16). The channel key from
|
|
64
|
+
* pairing is MANDATORY: relay payloads are ALWAYS encrypted (spec §8) - the relay is
|
|
65
|
+
* E2E-blind and must stay that way. */
|
|
66
|
+
static relay(options) {
|
|
67
|
+
if (!options.sessionKey || options.sessionKey.length !== 32) {
|
|
68
|
+
throw new LinkError(LinkErrorCode.InvalidParams, 'Relay requires the 32-byte pairing session key');
|
|
69
|
+
}
|
|
70
|
+
return new PhantasmaLink5(new RelayTransport(options), {
|
|
71
|
+
sessionKey: options.sessionKey,
|
|
72
|
+
// Relay round-trips include human consents too; same generous default.
|
|
73
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEEPLINK_REQUEST_TIMEOUT_MS,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a client for the ecdh pairing fallback (spec §15/§18.1): the custom-scheme
|
|
78
|
+
* channel is hijackable, so NO secret rides the pairing URI - only the dApp's
|
|
79
|
+
* ephemeral X25519 PUBLIC key. The wallet answers over the relay with its own public
|
|
80
|
+
* key plus the sealed connect result; the session key is derived on arrival and the
|
|
81
|
+
* client refuses to send (or accept) anything before that. Show {@link pairingUri}
|
|
82
|
+
* (a phantasma:// URI) to start; the session then arrives one-tap.
|
|
83
|
+
*/
|
|
84
|
+
static relayEcdh(options) {
|
|
85
|
+
const topic = options.topic ?? randomToken(32);
|
|
86
|
+
const pair = options.keyPair ?? generateEphemeralKeyPair();
|
|
87
|
+
const relayUrl = options.url ?? DEFAULT_RELAY_URL;
|
|
88
|
+
const transport = new RelayTransport({
|
|
89
|
+
...options,
|
|
90
|
+
topic,
|
|
91
|
+
url: relayUrl,
|
|
92
|
+
onWalletKey: (publicKeyB64Url) => {
|
|
93
|
+
// Fires only when the wallet's hop arrives, long after `client` below exists.
|
|
94
|
+
const walletPublicKey = base64UrlToBytes(publicKeyB64Url);
|
|
95
|
+
client.session.setSessionKey(deriveSessionKey(walletPublicKey, pair.secretKey));
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const client = new PhantasmaLink5(transport, {
|
|
99
|
+
dapp: options.dapp,
|
|
100
|
+
requireSessionKey: true,
|
|
101
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEEPLINK_REQUEST_TIMEOUT_MS,
|
|
102
|
+
});
|
|
103
|
+
client.pairingUriValue = buildPairingUri({
|
|
104
|
+
topic,
|
|
105
|
+
mode: 'ecdh',
|
|
106
|
+
dappPublicKey: pair.publicKey,
|
|
107
|
+
relay: relayUrl,
|
|
108
|
+
meta: options.dapp,
|
|
109
|
+
// The whole point of ecdh is the hijackable custom scheme; a safe channel
|
|
110
|
+
// (universal link / QR) should use the simpler sym mode instead.
|
|
111
|
+
scheme: 'scheme',
|
|
112
|
+
});
|
|
113
|
+
return client;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build a ready-to-use client for a WEB dApp over the deeplink transport (spec §17),
|
|
117
|
+
* bundling the per-dApp glue: pairing material generation, the pairing URI, persistence
|
|
118
|
+
* (localStorage by default), restore + session resume across page loads, and intake of
|
|
119
|
+
* the response URLs the wallet opens back at the page (initial URL + `hashchange`).
|
|
120
|
+
*
|
|
121
|
+
* The factory itself never opens a URL: mobile browsers only allow app-opening
|
|
122
|
+
* navigation from a user gesture. The dApp drives the hops - show {@link pairingUri}
|
|
123
|
+
* (link/QR) for the one-time pairing consent, then call {@link connect} and the typed
|
|
124
|
+
* methods from click handlers; an established session resumes promptlessly (spec §7).
|
|
125
|
+
*/
|
|
126
|
+
static async webDeeplink(options) {
|
|
127
|
+
const hooks = resolveWebDeeplinkHooks(options);
|
|
128
|
+
let record = loadWebDeeplinkRecord(hooks.storage, hooks.storageKey);
|
|
129
|
+
if (!record) {
|
|
130
|
+
// Persist BEFORE any URL hop: opening the wallet may unload this page, and the
|
|
131
|
+
// pairing key must already be on disk to decrypt responses after it comes back.
|
|
132
|
+
record = createWebDeeplinkRecord(options.callback ?? stripUrlFragment(hooks.pageUrl()));
|
|
133
|
+
saveWebDeeplinkRecord(hooks.storage, hooks.storageKey, record);
|
|
134
|
+
}
|
|
135
|
+
// The record is the source of truth from here on (a snapshot for the closures below).
|
|
136
|
+
const stored = record;
|
|
137
|
+
const sessionKey = base64UrlToBytes(stored.key);
|
|
138
|
+
const client = new PhantasmaLink5(new DeeplinkTransport({
|
|
139
|
+
topic: stored.topic,
|
|
140
|
+
opener: hooks.opener,
|
|
141
|
+
walletBase: options.walletBase,
|
|
142
|
+
}), {
|
|
143
|
+
dapp: options.dapp,
|
|
144
|
+
sessionKey,
|
|
145
|
+
sessionId: stored.sessionId,
|
|
146
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEEPLINK_REQUEST_TIMEOUT_MS,
|
|
147
|
+
onSessionChange: (connect) => {
|
|
148
|
+
// Session layer only - the pairing material stays valid across disconnects.
|
|
149
|
+
stored.sessionId = connect?.session.id;
|
|
150
|
+
stored.lastConnect = connect;
|
|
151
|
+
saveWebDeeplinkRecord(hooks.storage, hooks.storageKey, stored);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
// The pairing URI is always available (idempotent to re-show); `sym` requires the
|
|
155
|
+
// domain-verified universal link - a symmetric key never rides a custom scheme (§15).
|
|
156
|
+
client.pairingUriValue = buildPairingUri({
|
|
157
|
+
topic: stored.topic,
|
|
158
|
+
mode: 'sym',
|
|
159
|
+
symKey: sessionKey,
|
|
160
|
+
callback: stored.callback,
|
|
161
|
+
meta: options.dapp,
|
|
162
|
+
scheme: 'universal',
|
|
163
|
+
host: options.host,
|
|
164
|
+
});
|
|
165
|
+
// Hop-free hydration: a reloaded page shows the cached account immediately; the next
|
|
166
|
+
// real request (or a connect()) proves liveness and refreshes it.
|
|
167
|
+
client.lastConnect = stored.lastConnect;
|
|
168
|
+
const consumePageUrl = () => {
|
|
169
|
+
const href = hooks.pageUrl();
|
|
170
|
+
if (client.deliverUrl(href)) {
|
|
171
|
+
// Drop the consumed fragment so reload/back cannot re-deliver it and the
|
|
172
|
+
// ciphertext does not linger in the address bar (or get copied with the URL).
|
|
173
|
+
hooks.replaceUrl?.(stripUrlFragment(href));
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
// A response may already sit in the page URL (the wallet's callback navigation
|
|
177
|
+
// cold-loaded this page); consume it before subscribing to changes.
|
|
178
|
+
consumePageUrl();
|
|
179
|
+
const unsubscribe = hooks.onUrlChange?.(consumePageUrl);
|
|
180
|
+
if (unsubscribe) {
|
|
181
|
+
client.disposers.push(unsubscribe);
|
|
182
|
+
}
|
|
183
|
+
return client;
|
|
184
|
+
}
|
|
185
|
+
/** Feed an OS-delivered URL into a deeplink-backed client; see DeeplinkTransport.deliverUrl.
|
|
186
|
+
* The web-deeplink factory wires this automatically; SPAs whose routing swallows
|
|
187
|
+
* `hashchange` call it explicitly on route events. */
|
|
188
|
+
deliverUrl(url) {
|
|
189
|
+
return this.transport instanceof DeeplinkTransport && this.transport.deliverUrl(url);
|
|
190
|
+
}
|
|
191
|
+
/** Take (and clear) a wallet response that arrived with no in-flight request to match it -
|
|
192
|
+
* the deeplink reload case, where the page that issued the request was discarded before the
|
|
193
|
+
* answer returned. The web-deeplink factory delivers it during construction, so read it right
|
|
194
|
+
* after building the client. Returns undefined when there is nothing to recover. */
|
|
195
|
+
takeUnmatchedResponse() {
|
|
196
|
+
const response = this.lastUnmatched;
|
|
197
|
+
this.lastUnmatched = undefined;
|
|
198
|
+
return response;
|
|
199
|
+
}
|
|
200
|
+
/** The pairing URI for this client's channel (set by pairing-capable factories such as
|
|
201
|
+
* {@link webDeeplink}); render it as a link/QR for the one-time wallet pairing consent. */
|
|
202
|
+
get pairingUri() {
|
|
203
|
+
return this.pairingUriValue;
|
|
204
|
+
}
|
|
205
|
+
/** The account from the last successful {@link connect}, if any. */
|
|
206
|
+
get account() {
|
|
207
|
+
return this.lastConnect?.account;
|
|
208
|
+
}
|
|
209
|
+
/** The capabilities granted at the last successful {@link connect}, if any. */
|
|
210
|
+
get capabilities() {
|
|
211
|
+
return this.lastConnect?.capabilities;
|
|
212
|
+
}
|
|
213
|
+
/** Pair/resume and run the capability handshake. The wallet MAY grant a subset of the
|
|
214
|
+
* requested capabilities; inspect the returned {@link ConnectResult}. `dapp` falls back
|
|
215
|
+
* to `options.dapp` (factories set it), so a configured client connects with no args. */
|
|
216
|
+
async connect(dapp, extra = {}) {
|
|
217
|
+
const identity = dapp ?? this.defaultDapp;
|
|
218
|
+
if (!identity) {
|
|
219
|
+
throw new LinkError(LinkErrorCode.InvalidParams, 'connect() needs dApp metadata: pass it here or set options.dapp');
|
|
220
|
+
}
|
|
221
|
+
const params = { dapp: identity, ...extra };
|
|
222
|
+
// Default to resuming the current session (spec §7). Always safe: a matching wallet
|
|
223
|
+
// resumes promptlessly, any mismatch silently falls back to a fresh consent prompt.
|
|
224
|
+
if (params.session === undefined && this.session.getSessionId() !== undefined) {
|
|
225
|
+
params.session = this.session.getSessionId();
|
|
226
|
+
}
|
|
227
|
+
const result = await this.request(LinkMethod.Connect, params);
|
|
228
|
+
this.adoptConnectResult(result);
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
/** Make a connect result the live session state (used by both the explicit connect and
|
|
232
|
+
* the wallet-pushed {@link LinkEvent.SessionEstablished} one-tap pairing path). */
|
|
233
|
+
adoptConnectResult(result) {
|
|
234
|
+
this.lastConnect = result;
|
|
235
|
+
this.session.setSessionId(result.session.id);
|
|
236
|
+
this.onSessionChange?.(result);
|
|
237
|
+
}
|
|
238
|
+
getAccounts() {
|
|
239
|
+
return this.request(LinkMethod.GetAccounts);
|
|
240
|
+
}
|
|
241
|
+
getChains() {
|
|
242
|
+
return this.request(LinkMethod.GetChains);
|
|
243
|
+
}
|
|
244
|
+
getWalletInfo() {
|
|
245
|
+
return this.request(LinkMethod.GetWalletInfo);
|
|
246
|
+
}
|
|
247
|
+
signMessage(params) {
|
|
248
|
+
return this.request(LinkMethod.SignMessage, params);
|
|
249
|
+
}
|
|
250
|
+
/** Sign a transaction without broadcasting; the dApp submits the returned signed tx. */
|
|
251
|
+
signTransaction(params) {
|
|
252
|
+
return this.request(LinkMethod.SignTransaction, params);
|
|
253
|
+
}
|
|
254
|
+
/** Sign AND broadcast a transaction via the format's RPC endpoint. */
|
|
255
|
+
sendTransaction(params) {
|
|
256
|
+
return this.request(LinkMethod.SendTransaction, params);
|
|
257
|
+
}
|
|
258
|
+
/** Read-only VM invoke (no keys, no approval). */
|
|
259
|
+
invokeScript(params) {
|
|
260
|
+
return this.request(LinkMethod.InvokeScript, params);
|
|
261
|
+
}
|
|
262
|
+
async disconnect() {
|
|
263
|
+
const result = await this.request(LinkMethod.Disconnect);
|
|
264
|
+
// The wallet dropped the session; clear local state so later requests do not carry a
|
|
265
|
+
// dead session id (and embedders can erase their persisted copy).
|
|
266
|
+
this.lastConnect = undefined;
|
|
267
|
+
this.session.setSessionId(undefined);
|
|
268
|
+
this.onSessionChange?.(undefined);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
/** Drop the local (and persisted) session WITHOUT notifying the wallet. Use when a wallet
|
|
272
|
+
* round-trip is undesirable - notably deeplink, where pha_disconnect would navigate to the
|
|
273
|
+
* wallet and reload this page, so the cleanup after the request never runs and the session
|
|
274
|
+
* would resume on the next load. The wallet's side lapses on its own session TTL (spec §7). */
|
|
275
|
+
forgetSession() {
|
|
276
|
+
this.lastConnect = undefined;
|
|
277
|
+
this.session.setSessionId(undefined);
|
|
278
|
+
this.onSessionChange?.(undefined);
|
|
279
|
+
}
|
|
280
|
+
/** Subscribe to wallet->dApp events; returns an unsubscribe function. */
|
|
281
|
+
onEvent(handler) {
|
|
282
|
+
return this.session.onEvent(handler);
|
|
283
|
+
}
|
|
284
|
+
/** Close the underlying transport and reject any in-flight requests. */
|
|
285
|
+
close() {
|
|
286
|
+
// Release factory-registered hooks first (e.g. the web page-URL listener) so nothing
|
|
287
|
+
// can deliver into a closing client; disposers must never block the close itself.
|
|
288
|
+
for (const dispose of this.disposers.splice(0)) {
|
|
289
|
+
try {
|
|
290
|
+
dispose();
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// A failing disposer must not prevent the transport from closing.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.session.close();
|
|
297
|
+
}
|
|
298
|
+
// The wallet's result is `unknown` on the wire; we cast to the method's contract type. The
|
|
299
|
+
// shapes are validated structurally at the envelope layer (§4); per-field validation can be
|
|
300
|
+
// layered on later without changing call sites. `params` is `object` so typed param
|
|
301
|
+
// interfaces (which lack an index signature) are accepted, then forwarded as a plain record.
|
|
302
|
+
request(method, params) {
|
|
303
|
+
return this.session.request(method, params);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Structural check for a wallet-pushed connect result before adopting it as session state.
|
|
307
|
+
* The frame already authenticated via the channel key, so this guards against a malformed
|
|
308
|
+
* wallet payload, not an attacker; only the fields the client dereferences are checked. */
|
|
309
|
+
function isConnectResult(data) {
|
|
310
|
+
if (!data || typeof data !== 'object') {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const record = data;
|
|
314
|
+
return (typeof record.session === 'object' &&
|
|
315
|
+
record.session !== null &&
|
|
316
|
+
typeof record.session.id === 'string' &&
|
|
317
|
+
record.session.id.length > 0 &&
|
|
318
|
+
typeof record.account === 'object' &&
|
|
319
|
+
record.account !== null);
|
|
320
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Phantasma Link v5 - deeplink transport (spec §17). dApp and wallet live in separate apps on
|
|
2
|
+
// the SAME device and talk by opening URLs at each other:
|
|
3
|
+
// request: {walletBase}/v5/req#t=<topic>&f=<base64url(frame)> (dApp opens the wallet)
|
|
4
|
+
// response: {callback}#plv=5&t=<topic>&f=<base64url(frame)> (wallet opens the dApp back)
|
|
5
|
+
// One request = one URL hop each way ("ping-pong"), sized for SMALL operations; big payloads go
|
|
6
|
+
// over the relay (spec §16). Frames on this transport are ALWAYS the encrypted envelope
|
|
7
|
+
// {nonce, ct} sealed with the pairing session key - custom-scheme URLs are interceptable, so
|
|
8
|
+
// plaintext is never allowed here (enforced by PhantasmaLink5.deeplink()).
|
|
9
|
+
//
|
|
10
|
+
// The transport cannot "listen": responses arrive when the OS (re)opens the dApp with a new
|
|
11
|
+
// URL. The dApp wires that OS event into deliverUrl() (page load / visibilitychange /
|
|
12
|
+
// appUrlOpen, depending on platform).
|
|
13
|
+
import { LinkError, LinkErrorCode } from './errors.js';
|
|
14
|
+
import { bytesToBase64Url, base64UrlToBytes, utf8ToBytes, bytesToUtf8 } from './encoding.js';
|
|
15
|
+
/** Default custom-scheme base of the wallet ("phantasma://v5/req"). */
|
|
16
|
+
export const WALLET_SCHEME_BASE = 'phantasma://';
|
|
17
|
+
/** Default per-request timeout on deeplink transports. A round-trip spans an app switch
|
|
18
|
+
* plus a human consent, so the generic 60 s session default is far too tight here - it
|
|
19
|
+
* would expire the money path while the user is still reading the wallet's Send screen. */
|
|
20
|
+
export const DEEPLINK_REQUEST_TIMEOUT_MS = 300000;
|
|
21
|
+
/** Build the dApp->wallet request URL. `walletBase` is e.g. `phantasma:/` (custom scheme) or
|
|
22
|
+
* `https://link.phantasma.info` (universal link); the path is fixed to /v5/req. */
|
|
23
|
+
export function buildRequestUrl(walletBase, topic, frame) {
|
|
24
|
+
const base = walletBase.endsWith('/') ? walletBase : `${walletBase}/`;
|
|
25
|
+
return `${base}v5/req#t=${encodeURIComponent(topic)}&f=${bytesToBase64Url(utf8ToBytes(frame))}`;
|
|
26
|
+
}
|
|
27
|
+
/** Parse a dApp->wallet request URL; null when the URL is not a v5 request deeplink. */
|
|
28
|
+
export function parseRequestUrl(url) {
|
|
29
|
+
const hashIndex = url.indexOf('#');
|
|
30
|
+
if (hashIndex < 0 || !url.slice(0, hashIndex).endsWith('/v5/req')) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return parseFragment(url.slice(hashIndex + 1), false);
|
|
34
|
+
}
|
|
35
|
+
/** Build the wallet->dApp response URL onto the dApp's pairing callback. */
|
|
36
|
+
export function buildResponseUrl(callback, topic, frame) {
|
|
37
|
+
const hashIndex = callback.indexOf('#');
|
|
38
|
+
const base = hashIndex < 0 ? callback : callback.slice(0, hashIndex);
|
|
39
|
+
return `${base}#plv=5&t=${encodeURIComponent(topic)}&f=${bytesToBase64Url(utf8ToBytes(frame))}`;
|
|
40
|
+
}
|
|
41
|
+
/** Parse a wallet->dApp response URL; null when the URL is not a v5 response deeplink. */
|
|
42
|
+
export function parseResponseUrl(url) {
|
|
43
|
+
const hashIndex = url.indexOf('#');
|
|
44
|
+
if (hashIndex < 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return parseFragment(url.slice(hashIndex + 1), true);
|
|
48
|
+
}
|
|
49
|
+
function parseFragment(fragment, requirePlvMarker) {
|
|
50
|
+
const params = new URLSearchParams(fragment);
|
|
51
|
+
if (requirePlvMarker && params.get('plv') !== '5') {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const topic = params.get('t');
|
|
55
|
+
const f = params.get('f');
|
|
56
|
+
if (!topic || !f) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return { topic, frame: bytesToUtf8(base64UrlToBytes(f)) };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* {@link LinkTransport} over deeplink ping-pong. `send` opens a wallet URL carrying the frame;
|
|
68
|
+
* the dApp feeds OS-delivered URLs into {@link deliverUrl}, which dispatches frames for this
|
|
69
|
+
* transport's topic to the session client.
|
|
70
|
+
*/
|
|
71
|
+
export class DeeplinkTransport {
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.closed = false;
|
|
74
|
+
if (!options.topic) {
|
|
75
|
+
throw new LinkError(LinkErrorCode.InvalidParams, 'Deeplink transport requires a topic');
|
|
76
|
+
}
|
|
77
|
+
this.topic = options.topic;
|
|
78
|
+
this.opener = options.opener;
|
|
79
|
+
this.walletBase = options.walletBase ?? WALLET_SCHEME_BASE;
|
|
80
|
+
}
|
|
81
|
+
send(frame) {
|
|
82
|
+
if (this.closed) {
|
|
83
|
+
throw new LinkError(LinkErrorCode.Disconnected, 'Deeplink transport is closed');
|
|
84
|
+
}
|
|
85
|
+
this.opener(buildRequestUrl(this.walletBase, this.topic, frame));
|
|
86
|
+
}
|
|
87
|
+
onMessage(handler) {
|
|
88
|
+
this.messageHandler = handler;
|
|
89
|
+
}
|
|
90
|
+
onClose(handler) {
|
|
91
|
+
this.closeHandler = handler;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Feed an OS-delivered URL into the transport. Returns true when the URL was a v5 response
|
|
95
|
+
* for THIS transport's topic and was dispatched; false lets the caller try other handlers.
|
|
96
|
+
*/
|
|
97
|
+
deliverUrl(url) {
|
|
98
|
+
if (this.closed) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const parsed = parseResponseUrl(url);
|
|
102
|
+
if (!parsed || parsed.topic !== this.topic) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
this.messageHandler?.(parsed.frame);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
if (this.closed) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.closed = true;
|
|
113
|
+
this.closeHandler?.('Deeplink transport closed');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Phantasma Link v5 - encoding helpers. The transport carries binary (ciphertext, nonces,
|
|
2
|
+
// keys, serialized txs) as base64 (spec §4 / §18), which halves the wire size vs the
|
|
3
|
+
// v1-v4 hex. Implemented dependency-free and environment-agnostic (browser + Node), since
|
|
4
|
+
// the SDK ships to both and `Buffer`/`btoa` are not uniformly available.
|
|
5
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
6
|
+
// Reverse lookup table: byte value of each base64 character, or -1 if not a base64 char.
|
|
7
|
+
const B64_LOOKUP = (() => {
|
|
8
|
+
const table = new Int8Array(256).fill(-1);
|
|
9
|
+
for (let i = 0; i < B64_ALPHABET.length; i++) {
|
|
10
|
+
table[B64_ALPHABET.charCodeAt(i)] = i;
|
|
11
|
+
}
|
|
12
|
+
return table;
|
|
13
|
+
})();
|
|
14
|
+
/** Encode bytes as standard base64 (with `=` padding). */
|
|
15
|
+
export function bytesToBase64(bytes) {
|
|
16
|
+
let out = '';
|
|
17
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
18
|
+
const b0 = bytes[i];
|
|
19
|
+
const hasB1 = i + 1 < bytes.length;
|
|
20
|
+
const hasB2 = i + 2 < bytes.length;
|
|
21
|
+
const b1 = hasB1 ? bytes[i + 1] : 0;
|
|
22
|
+
const b2 = hasB2 ? bytes[i + 2] : 0;
|
|
23
|
+
out += B64_ALPHABET[b0 >> 2];
|
|
24
|
+
out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
|
|
25
|
+
out += hasB1 ? B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] : '=';
|
|
26
|
+
out += hasB2 ? B64_ALPHABET[b2 & 0x3f] : '=';
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Decode standard or url-safe base64 (padding optional) to bytes. Throws on invalid input
|
|
31
|
+
* so a malformed frame fails loudly rather than silently producing garbage. */
|
|
32
|
+
export function base64ToBytes(input) {
|
|
33
|
+
// Normalize url-safe alphabet and drop padding; we recompute length from content.
|
|
34
|
+
let clean = '';
|
|
35
|
+
for (let i = 0; i < input.length; i++) {
|
|
36
|
+
const c = input[i];
|
|
37
|
+
if (c === '-') {
|
|
38
|
+
clean += '+';
|
|
39
|
+
}
|
|
40
|
+
else if (c === '_') {
|
|
41
|
+
clean += '/';
|
|
42
|
+
}
|
|
43
|
+
else if (c !== '=') {
|
|
44
|
+
clean += c;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const fullGroups = Math.floor(clean.length / 4);
|
|
48
|
+
const remainder = clean.length % 4;
|
|
49
|
+
if (remainder === 1) {
|
|
50
|
+
throw new Error('Invalid base64 string: dangling character');
|
|
51
|
+
}
|
|
52
|
+
const outLength = fullGroups * 3 + (remainder === 0 ? 0 : remainder - 1);
|
|
53
|
+
const out = new Uint8Array(outLength);
|
|
54
|
+
let o = 0;
|
|
55
|
+
let acc = 0;
|
|
56
|
+
let accBits = 0;
|
|
57
|
+
for (let i = 0; i < clean.length; i++) {
|
|
58
|
+
const v = B64_LOOKUP[clean.charCodeAt(i)];
|
|
59
|
+
if (v < 0) {
|
|
60
|
+
throw new Error('Invalid base64 character');
|
|
61
|
+
}
|
|
62
|
+
acc = (acc << 6) | v;
|
|
63
|
+
accBits += 6;
|
|
64
|
+
if (accBits >= 8) {
|
|
65
|
+
accBits -= 8;
|
|
66
|
+
out[o++] = (acc >> accBits) & 0xff;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Encode bytes as url-safe base64 without padding (for use in URL fragments, spec §15). */
|
|
72
|
+
export function bytesToBase64Url(bytes) {
|
|
73
|
+
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
74
|
+
}
|
|
75
|
+
/** Decode url-safe (or standard) base64 to bytes; alias of {@link base64ToBytes}, which
|
|
76
|
+
* already accepts both alphabets. */
|
|
77
|
+
export function base64UrlToBytes(input) {
|
|
78
|
+
return base64ToBytes(input);
|
|
79
|
+
}
|
|
80
|
+
/** UTF-8 encode a string to bytes (uses TextEncoder, present in all supported runtimes). */
|
|
81
|
+
export function utf8ToBytes(text) {
|
|
82
|
+
return new TextEncoder().encode(text);
|
|
83
|
+
}
|
|
84
|
+
/** UTF-8 decode bytes to a string. */
|
|
85
|
+
export function bytesToUtf8(bytes) {
|
|
86
|
+
return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Phantasma Link v5 - message envelope (spec §4). A JSON-RPC-2.0 profile that replaces the
|
|
2
|
+
// v1-v4 delimiter-joined string: typed named params, numeric error codes, and request
|
|
3
|
+
// correlation by `id`. One logical message = one envelope.
|
|
4
|
+
import { PLV } from './protocol.js';
|
|
5
|
+
import { LinkError, LinkErrorCode } from './errors.js';
|
|
6
|
+
export function isLinkRequest(msg) {
|
|
7
|
+
return 'method' in msg && typeof msg.method === 'string';
|
|
8
|
+
}
|
|
9
|
+
export function isLinkEvent(msg) {
|
|
10
|
+
return msg.type === 'event';
|
|
11
|
+
}
|
|
12
|
+
export function isLinkErrorResponse(msg) {
|
|
13
|
+
return 'error' in msg && !!msg.error;
|
|
14
|
+
}
|
|
15
|
+
export function isLinkSuccessResponse(msg) {
|
|
16
|
+
return 'result' in msg;
|
|
17
|
+
}
|
|
18
|
+
/** Serialize an envelope to its on-the-wire JSON text. */
|
|
19
|
+
export function encodeEnvelope(message) {
|
|
20
|
+
return JSON.stringify(message);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parse and validate an on-the-wire JSON text into a {@link LinkMessage}.
|
|
24
|
+
*
|
|
25
|
+
* Throws {@link LinkError} with `ParseError` for non-JSON and `InvalidRequest` for a JSON
|
|
26
|
+
* value that is not a well-formed v5 envelope (wrong `plv`, missing `id`, or a shape that
|
|
27
|
+
* is neither request, response, nor event). Validation happens here, once, so every
|
|
28
|
+
* downstream consumer can trust the structure.
|
|
29
|
+
*/
|
|
30
|
+
export function decodeEnvelope(text) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new LinkError(LinkErrorCode.ParseError, 'Phantasma Link message is not valid JSON');
|
|
37
|
+
}
|
|
38
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
39
|
+
throw new LinkError(LinkErrorCode.InvalidRequest, 'Phantasma Link message must be an object');
|
|
40
|
+
}
|
|
41
|
+
const record = parsed;
|
|
42
|
+
if (record.plv !== PLV) {
|
|
43
|
+
throw new LinkError(LinkErrorCode.InvalidRequest, `Unsupported Phantasma Link protocol version: ${String(record.plv)}`);
|
|
44
|
+
}
|
|
45
|
+
// Event messages have no `id` and are matched by the `type` discriminator first.
|
|
46
|
+
if (record.type === 'event') {
|
|
47
|
+
if (typeof record.event !== 'string') {
|
|
48
|
+
throw new LinkError(LinkErrorCode.InvalidRequest, 'Event envelope is missing `event`');
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
if (typeof record.id !== 'string' || record.id.length === 0) {
|
|
53
|
+
throw new LinkError(LinkErrorCode.InvalidRequest, 'Envelope is missing a string `id`');
|
|
54
|
+
}
|
|
55
|
+
if (typeof record.method === 'string') {
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
if ('result' in record) {
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
if (record.error && typeof record.error === 'object') {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
throw new LinkError(LinkErrorCode.InvalidRequest, 'Envelope is neither a request, response, nor event');
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Phantasma Link v5 - structured error taxonomy (spec §10). Replaces the v1-v4 free-text
|
|
2
|
+
// error strings that callers had to substring-match.
|
|
3
|
+
/** Numeric error codes. JSON-RPC reserved range + EIP-1193-aligned app codes + Phantasma
|
|
4
|
+
* specific codes. Carried in {@link LinkErrorObject.code}. */
|
|
5
|
+
export const LinkErrorCode = {
|
|
6
|
+
// JSON-RPC reserved.
|
|
7
|
+
ParseError: -32700,
|
|
8
|
+
InvalidRequest: -32600,
|
|
9
|
+
MethodNotFound: -32601,
|
|
10
|
+
InvalidParams: -32602,
|
|
11
|
+
InternalError: -32603,
|
|
12
|
+
// App-level (EIP-1193-aligned where sensible).
|
|
13
|
+
UserRejected: 4001,
|
|
14
|
+
Unauthorized: 4100,
|
|
15
|
+
Disconnected: 4900,
|
|
16
|
+
UnsupportedChain: 4902,
|
|
17
|
+
// Phantasma-specific.
|
|
18
|
+
PayloadTooLarge: 5001,
|
|
19
|
+
NexusMismatch: 5002,
|
|
20
|
+
UnsupportedSignatureKind: 5003,
|
|
21
|
+
CapabilityNotSupported: 5004,
|
|
22
|
+
SessionExpired: 5100,
|
|
23
|
+
SessionRevoked: 5101,
|
|
24
|
+
};
|
|
25
|
+
/** Error thrown by the v5 client and carried over the wire as {@link LinkErrorObject}.
|
|
26
|
+
* Keeping a dedicated class lets callers branch on the numeric `code` instead of parsing
|
|
27
|
+
* message text. */
|
|
28
|
+
export class LinkError extends Error {
|
|
29
|
+
constructor(code, message, data) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'LinkError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.data = data;
|
|
34
|
+
// Preserve `instanceof LinkError` across the transpiled-to-ES2020 target.
|
|
35
|
+
Object.setPrototypeOf(this, LinkError.prototype);
|
|
36
|
+
}
|
|
37
|
+
toObject() {
|
|
38
|
+
// `data` is omitted entirely when undefined so the serialized shape stays minimal.
|
|
39
|
+
return this.data === undefined
|
|
40
|
+
? { code: this.code, message: this.message }
|
|
41
|
+
: { code: this.code, message: this.message, data: this.data };
|
|
42
|
+
}
|
|
43
|
+
/** Reconstruct a {@link LinkError} from a received error object, tolerating malformed
|
|
44
|
+
* inputs (a peer may send a non-conforming shape). */
|
|
45
|
+
static fromObject(obj) {
|
|
46
|
+
if (obj && typeof obj === 'object') {
|
|
47
|
+
const record = obj;
|
|
48
|
+
const code = typeof record.code === 'number' ? record.code : LinkErrorCode.InternalError;
|
|
49
|
+
const message = typeof record.message === 'string' && record.message.length > 0
|
|
50
|
+
? record.message
|
|
51
|
+
: 'Phantasma Link error';
|
|
52
|
+
return new LinkError(code, message, record.data);
|
|
53
|
+
}
|
|
54
|
+
return new LinkError(LinkErrorCode.InternalError, 'Phantasma Link error');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Phantasma Link v5 - public barrel for the new protocol generation. Import via
|
|
2
|
+
// `phantasma-sdk-ts/link/v5` or the `PhantasmaLinkV5` namespace from the package root.
|
|
3
|
+
// The legacy v1-v4 client (`PhantasmaLink`) remains available and unchanged.
|
|
4
|
+
export * from './protocol.js';
|
|
5
|
+
export * from './errors.js';
|
|
6
|
+
export * from './encoding.js';
|
|
7
|
+
export * from './envelope.js';
|
|
8
|
+
export * from './capabilities.js';
|
|
9
|
+
export * from './methods.js';
|
|
10
|
+
export * from './session-crypto.js';
|
|
11
|
+
export * from './pairing.js';
|
|
12
|
+
export * from './transport.js';
|
|
13
|
+
export * from './loopback-transport.js';
|
|
14
|
+
export * from './deeplink.js';
|
|
15
|
+
export * from './relay-transport.js';
|
|
16
|
+
export * from './web-deeplink.js';
|
|
17
|
+
export * from './client.js';
|