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,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - structured error taxonomy (spec §10). Replaces the v1-v4 free-text
|
|
3
|
+
// error strings that callers had to substring-match.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.LinkError = exports.LinkErrorCode = void 0;
|
|
6
|
+
/** Numeric error codes. JSON-RPC reserved range + EIP-1193-aligned app codes + Phantasma
|
|
7
|
+
* specific codes. Carried in {@link LinkErrorObject.code}. */
|
|
8
|
+
exports.LinkErrorCode = {
|
|
9
|
+
// JSON-RPC reserved.
|
|
10
|
+
ParseError: -32700,
|
|
11
|
+
InvalidRequest: -32600,
|
|
12
|
+
MethodNotFound: -32601,
|
|
13
|
+
InvalidParams: -32602,
|
|
14
|
+
InternalError: -32603,
|
|
15
|
+
// App-level (EIP-1193-aligned where sensible).
|
|
16
|
+
UserRejected: 4001,
|
|
17
|
+
Unauthorized: 4100,
|
|
18
|
+
Disconnected: 4900,
|
|
19
|
+
UnsupportedChain: 4902,
|
|
20
|
+
// Phantasma-specific.
|
|
21
|
+
PayloadTooLarge: 5001,
|
|
22
|
+
NexusMismatch: 5002,
|
|
23
|
+
UnsupportedSignatureKind: 5003,
|
|
24
|
+
CapabilityNotSupported: 5004,
|
|
25
|
+
SessionExpired: 5100,
|
|
26
|
+
SessionRevoked: 5101,
|
|
27
|
+
};
|
|
28
|
+
/** Error thrown by the v5 client and carried over the wire as {@link LinkErrorObject}.
|
|
29
|
+
* Keeping a dedicated class lets callers branch on the numeric `code` instead of parsing
|
|
30
|
+
* message text. */
|
|
31
|
+
class LinkError extends Error {
|
|
32
|
+
constructor(code, message, data) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'LinkError';
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.data = data;
|
|
37
|
+
// Preserve `instanceof LinkError` across the transpiled-to-ES2020 target.
|
|
38
|
+
Object.setPrototypeOf(this, LinkError.prototype);
|
|
39
|
+
}
|
|
40
|
+
toObject() {
|
|
41
|
+
// `data` is omitted entirely when undefined so the serialized shape stays minimal.
|
|
42
|
+
return this.data === undefined
|
|
43
|
+
? { code: this.code, message: this.message }
|
|
44
|
+
: { code: this.code, message: this.message, data: this.data };
|
|
45
|
+
}
|
|
46
|
+
/** Reconstruct a {@link LinkError} from a received error object, tolerating malformed
|
|
47
|
+
* inputs (a peer may send a non-conforming shape). */
|
|
48
|
+
static fromObject(obj) {
|
|
49
|
+
if (obj && typeof obj === 'object') {
|
|
50
|
+
const record = obj;
|
|
51
|
+
const code = typeof record.code === 'number' ? record.code : exports.LinkErrorCode.InternalError;
|
|
52
|
+
const message = typeof record.message === 'string' && record.message.length > 0
|
|
53
|
+
? record.message
|
|
54
|
+
: 'Phantasma Link error';
|
|
55
|
+
return new LinkError(code, message, record.data);
|
|
56
|
+
}
|
|
57
|
+
return new LinkError(exports.LinkErrorCode.InternalError, 'Phantasma Link error');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.LinkError = LinkError;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - public barrel for the new protocol generation. Import via
|
|
3
|
+
// `phantasma-sdk-ts/link/v5` or the `PhantasmaLinkV5` namespace from the package root.
|
|
4
|
+
// The legacy v1-v4 client (`PhantasmaLink`) remains available and unchanged.
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
17
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
__exportStar(require("./protocol.js"), exports);
|
|
21
|
+
__exportStar(require("./errors.js"), exports);
|
|
22
|
+
__exportStar(require("./encoding.js"), exports);
|
|
23
|
+
__exportStar(require("./envelope.js"), exports);
|
|
24
|
+
__exportStar(require("./capabilities.js"), exports);
|
|
25
|
+
__exportStar(require("./methods.js"), exports);
|
|
26
|
+
__exportStar(require("./session-crypto.js"), exports);
|
|
27
|
+
__exportStar(require("./pairing.js"), exports);
|
|
28
|
+
__exportStar(require("./transport.js"), exports);
|
|
29
|
+
__exportStar(require("./loopback-transport.js"), exports);
|
|
30
|
+
__exportStar(require("./deeplink.js"), exports);
|
|
31
|
+
__exportStar(require("./relay-transport.js"), exports);
|
|
32
|
+
__exportStar(require("./web-deeplink.js"), exports);
|
|
33
|
+
__exportStar(require("./client.js"), exports);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - loopback transport (spec §6.2). Desktop browser/app -> the wallet's
|
|
3
|
+
// local WebSocket server. This is the TRUSTED-LOCAL path: frames are plaintext v5 envelopes
|
|
4
|
+
// (no channel encryption needed; see LinkSessionClient with no session key). Default host is
|
|
5
|
+
// `localhost` (never the literal `127.0.0.1`).
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.LoopbackTransport = void 0;
|
|
8
|
+
const errors_js_1 = require("./errors.js");
|
|
9
|
+
function defaultWebSocketFactory(url) {
|
|
10
|
+
const globalWithWs = globalThis;
|
|
11
|
+
if (!globalWithWs.WebSocket) {
|
|
12
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.Disconnected, 'No WebSocket implementation available; pass options.webSocketFactory');
|
|
13
|
+
}
|
|
14
|
+
return new globalWithWs.WebSocket(url);
|
|
15
|
+
}
|
|
16
|
+
/** A {@link LinkTransport} over a local WebSocket to the wallet. Sends are buffered until the
|
|
17
|
+
* socket opens, then flushed, so callers can issue requests immediately after construction. */
|
|
18
|
+
class LoopbackTransport {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.outbox = [];
|
|
21
|
+
this.isOpen = false;
|
|
22
|
+
const host = options.host ?? 'localhost';
|
|
23
|
+
const port = options.port ?? 7090;
|
|
24
|
+
const path = options.path ?? '/phantasma/v5';
|
|
25
|
+
const url = `ws://${host}:${port}${path}`;
|
|
26
|
+
const factory = options.webSocketFactory ?? defaultWebSocketFactory;
|
|
27
|
+
this.socket = factory(url);
|
|
28
|
+
this.socket.onopen = () => {
|
|
29
|
+
this.isOpen = true;
|
|
30
|
+
for (const frame of this.outbox) {
|
|
31
|
+
this.socket.send(frame);
|
|
32
|
+
}
|
|
33
|
+
this.outbox.length = 0;
|
|
34
|
+
};
|
|
35
|
+
this.socket.onmessage = (event) => {
|
|
36
|
+
if (typeof event.data === 'string') {
|
|
37
|
+
this.messageHandler?.(event.data);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
this.socket.onclose = (event) => {
|
|
41
|
+
this.isOpen = false;
|
|
42
|
+
this.closeHandler?.(event?.reason);
|
|
43
|
+
};
|
|
44
|
+
// Errors surface as a subsequent close; nothing actionable to do here.
|
|
45
|
+
this.socket.onerror = () => { };
|
|
46
|
+
}
|
|
47
|
+
send(frame) {
|
|
48
|
+
if (this.isOpen) {
|
|
49
|
+
this.socket.send(frame);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.outbox.push(frame);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
onMessage(handler) {
|
|
56
|
+
this.messageHandler = handler;
|
|
57
|
+
}
|
|
58
|
+
onClose(handler) {
|
|
59
|
+
this.closeHandler = handler;
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
try {
|
|
63
|
+
this.socket.close();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Closing an already-closed socket is harmless.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.LoopbackTransport = LoopbackTransport;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - pairing URI build/parse (spec §15). The pairing material rides in the
|
|
3
|
+
// URL FRAGMENT (never sent to the server). Two channels decide the key-establishment mode:
|
|
4
|
+
// - `sym` : a 32-byte session key in the fragment - ONLY for safe channels (a
|
|
5
|
+
// domain-verified universal link, or a QR). Simplest + MITM-proof.
|
|
6
|
+
// - `ecdh` : only the dApp's ephemeral X25519 PUBLIC key - for the hijackable
|
|
7
|
+
// custom-scheme fallback, where no secret may appear.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.buildPairingUri = buildPairingUri;
|
|
10
|
+
exports.parsePairingUri = parsePairingUri;
|
|
11
|
+
const protocol_js_1 = require("./protocol.js");
|
|
12
|
+
const errors_js_1 = require("./errors.js");
|
|
13
|
+
const encoding_js_1 = require("./encoding.js");
|
|
14
|
+
/** Build a pairing URI. Enforces the security rule that a symmetric key (a secret) must
|
|
15
|
+
* NOT be placed in a hijackable custom-scheme URL (spec §15/§18). */
|
|
16
|
+
function buildPairingUri(input) {
|
|
17
|
+
const scheme = input.scheme ?? 'universal';
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
params.set('v', String(protocol_js_1.PLV));
|
|
20
|
+
params.set('t', input.topic);
|
|
21
|
+
if (input.relay) {
|
|
22
|
+
params.set('relay', input.relay);
|
|
23
|
+
}
|
|
24
|
+
if (input.callback) {
|
|
25
|
+
params.set('cb', input.callback);
|
|
26
|
+
}
|
|
27
|
+
if (input.mode === 'sym') {
|
|
28
|
+
if (!input.symKey) {
|
|
29
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidParams, 'sym pairing requires symKey');
|
|
30
|
+
}
|
|
31
|
+
if (scheme === 'scheme') {
|
|
32
|
+
// A scheme-squatting app could read this URL; never expose a secret there.
|
|
33
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidParams, 'A symmetric key must not be placed in a custom-scheme URL; use a universal link or QR');
|
|
34
|
+
}
|
|
35
|
+
params.set('sk', (0, encoding_js_1.bytesToBase64Url)(input.symKey));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
if (!input.dappPublicKey) {
|
|
39
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidParams, 'ecdh pairing requires dappPublicKey');
|
|
40
|
+
}
|
|
41
|
+
params.set('pk', (0, encoding_js_1.bytesToBase64Url)(input.dappPublicKey));
|
|
42
|
+
}
|
|
43
|
+
if (input.meta) {
|
|
44
|
+
params.set('meta', (0, encoding_js_1.bytesToBase64Url)((0, encoding_js_1.utf8ToBytes)(JSON.stringify(input.meta))));
|
|
45
|
+
}
|
|
46
|
+
const base = scheme === 'scheme'
|
|
47
|
+
? 'phantasma://v5/pair'
|
|
48
|
+
: `https://${input.host ?? protocol_js_1.DEFAULT_LINK_HOST}/v5/pair`;
|
|
49
|
+
return `${base}#${params.toString()}`;
|
|
50
|
+
}
|
|
51
|
+
/** Parse a pairing URI (universal-link or custom-scheme) into {@link PairingParams}. */
|
|
52
|
+
function parsePairingUri(uri) {
|
|
53
|
+
const hashIndex = uri.indexOf('#');
|
|
54
|
+
if (hashIndex < 0) {
|
|
55
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidRequest, 'Pairing URI has no fragment');
|
|
56
|
+
}
|
|
57
|
+
const params = new URLSearchParams(uri.slice(hashIndex + 1));
|
|
58
|
+
const version = Number(params.get('v'));
|
|
59
|
+
if (version !== protocol_js_1.PLV) {
|
|
60
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidRequest, `Unsupported pairing version: ${params.get('v')}`);
|
|
61
|
+
}
|
|
62
|
+
const topic = params.get('t');
|
|
63
|
+
if (!topic) {
|
|
64
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidRequest, 'Pairing URI is missing topic');
|
|
65
|
+
}
|
|
66
|
+
const relay = params.get('relay') ?? undefined;
|
|
67
|
+
const callback = params.get('cb') ?? undefined;
|
|
68
|
+
let meta;
|
|
69
|
+
const metaRaw = params.get('meta');
|
|
70
|
+
if (metaRaw) {
|
|
71
|
+
try {
|
|
72
|
+
meta = JSON.parse((0, encoding_js_1.bytesToUtf8)((0, encoding_js_1.base64UrlToBytes)(metaRaw)));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidRequest, 'Pairing URI has malformed meta');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const sk = params.get('sk');
|
|
79
|
+
const pk = params.get('pk');
|
|
80
|
+
if (sk) {
|
|
81
|
+
return { version, topic, relay, callback, mode: 'sym', symKey: (0, encoding_js_1.base64UrlToBytes)(sk), meta };
|
|
82
|
+
}
|
|
83
|
+
if (pk) {
|
|
84
|
+
return {
|
|
85
|
+
version,
|
|
86
|
+
topic,
|
|
87
|
+
relay,
|
|
88
|
+
callback,
|
|
89
|
+
mode: 'ecdh',
|
|
90
|
+
dappPublicKey: (0, encoding_js_1.base64UrlToBytes)(pk),
|
|
91
|
+
meta,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidRequest, 'Pairing URI carries neither sk nor pk');
|
|
95
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - protocol constants (the new generation; runs in parallel with the
|
|
3
|
+
// legacy v1-v4 string protocol in `../phantasma-link.ts`). See the design spec:
|
|
4
|
+
// codex-pha `.codex/context/link/phantasma-link-v5-spec.md`.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SignatureKind = exports.TxFormat = exports.LinkEvent = exports.LinkMethod = exports.DEFAULT_LINK_HOST = exports.PLV = void 0;
|
|
7
|
+
/** Protocol version carried in every v5 envelope (`plv`). A peer that does not recognize
|
|
8
|
+
* this value rejects the message with {@link LinkErrorCode.InvalidRequest}. */
|
|
9
|
+
exports.PLV = 5;
|
|
10
|
+
/** Default subdomain that hosts the universal links + AASA/assetlinks + relay (spec §17).
|
|
11
|
+
* NEVER `phantasma.io` (hostile). */
|
|
12
|
+
exports.DEFAULT_LINK_HOST = 'link.phantasma.info';
|
|
13
|
+
/** Request methods (dApp -> wallet). Namespaced `pha_*`, EIP-1193-aligned (spec §9). */
|
|
14
|
+
exports.LinkMethod = {
|
|
15
|
+
/** Pair or resume a session; returns the capability handshake + account + session. */
|
|
16
|
+
Connect: 'pha_connect',
|
|
17
|
+
/** End the session. */
|
|
18
|
+
Disconnect: 'pha_disconnect',
|
|
19
|
+
/** Account(s) authorized for this session. */
|
|
20
|
+
GetAccounts: 'pha_getAccounts',
|
|
21
|
+
/** Supported chains (CAIP-2) + current; `nexus` as a field. */
|
|
22
|
+
GetChains: 'pha_getChains',
|
|
23
|
+
/** Wallet name/version/capabilities/rpc. */
|
|
24
|
+
GetWalletInfo: 'pha_getWalletInfo',
|
|
25
|
+
/** Sign an arbitrary, non-transaction-forgeable message. */
|
|
26
|
+
SignMessage: 'pha_signMessage',
|
|
27
|
+
/** Sign a transaction only (do NOT broadcast); the dApp submits it. */
|
|
28
|
+
SignTransaction: 'pha_signTransaction',
|
|
29
|
+
/** Sign AND broadcast a transaction via the format's RPC endpoint. */
|
|
30
|
+
SendTransaction: 'pha_sendTransaction',
|
|
31
|
+
/** Read-only VM invoke (no keys, no approval). */
|
|
32
|
+
InvokeScript: 'pha_invokeScript',
|
|
33
|
+
};
|
|
34
|
+
/** Event names pushed wallet -> dApp on a persistent transport (spec §9.5 events caveat). */
|
|
35
|
+
exports.LinkEvent = {
|
|
36
|
+
AccountsChanged: 'pha_accountsChanged',
|
|
37
|
+
ChainChanged: 'pha_chainChanged',
|
|
38
|
+
SessionDeleted: 'pha_sessionDeleted',
|
|
39
|
+
SessionExpired: 'pha_sessionExpired',
|
|
40
|
+
/** Unsolicited connect result pushed right after a pairing approval (spec §15 step 3),
|
|
41
|
+
* letting the first connection complete in one user gesture. Unlike the other events it
|
|
42
|
+
* also rides the deeplink transport: the wallet is foreground at the approval moment, so
|
|
43
|
+
* it CAN open the callback (this is a reply to the pairing, not a spontaneous push).
|
|
44
|
+
* `data` carries the same shape as a `pha_connect` result. */
|
|
45
|
+
SessionEstablished: 'pha_sessionEstablished',
|
|
46
|
+
};
|
|
47
|
+
/** Transaction serialization format. Selects which RPC submission endpoint the wallet uses
|
|
48
|
+
* (spec §9.4): `script` -> SendRawTransaction (classic `Transaction`), `carbon` ->
|
|
49
|
+
* SendCarbonTransaction (Carbon `SignedTxMsg`). Routing is by serialization envelope, NOT
|
|
50
|
+
* by "contains a script" (a Carbon tx may also wrap a script). */
|
|
51
|
+
exports.TxFormat = {
|
|
52
|
+
Script: 'script',
|
|
53
|
+
Carbon: 'carbon',
|
|
54
|
+
};
|
|
55
|
+
/** Signature scheme used to sign a Phantasma payload (spec §9.7). Selects the key:
|
|
56
|
+
* `Ed25519` = the Phantasma key, `ECDSA` = the secp256k1 (ETH/BSC-interop) key. This is
|
|
57
|
+
* NOT native foreign-chain signing - the wallet always targets Phantasma. */
|
|
58
|
+
exports.SignatureKind = {
|
|
59
|
+
Ed25519: 'Ed25519',
|
|
60
|
+
ECDSA: 'ECDSA',
|
|
61
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phantasma Link v5 - relay transport (spec §6.4/§16). dApp and wallet meet on an
|
|
3
|
+
// E2E-blind pub/sub server: both subscribe to the pairing topic; every frame is
|
|
4
|
+
// published as OPAQUE payload (the NaCl-sealed envelope text - the relay never sees
|
|
5
|
+
// plaintext, enforced by PhantasmaLink5.relay() requiring the session key). Unlike
|
|
6
|
+
// deeplink this transport is persistent and bidirectional, so it carries big payloads,
|
|
7
|
+
// cross-device sessions, and wallet->dApp events.
|
|
8
|
+
//
|
|
9
|
+
// Mobile-network reality shapes this file: the socket reconnects with backoff and
|
|
10
|
+
// re-subscribes; publishes are tracked until the relay acks them and are re-sent after
|
|
11
|
+
// a reconnect (the wallet de-duplicates by envelope id, spec §18.2, so at-least-once
|
|
12
|
+
// is safe); frames above the relay's per-frame cap are chunked as
|
|
13
|
+
// {msgId, seq, total, chunk} and reassembled before the session layer ever sees them
|
|
14
|
+
// (spec §16). Keepalive needs nothing here: the relay SERVER pings, browsers auto-pong.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.RelayTransport = exports.RELAY_CHUNK_BYTES = exports.DEFAULT_RELAY_URL = void 0;
|
|
17
|
+
const errors_js_1 = require("./errors.js");
|
|
18
|
+
const protocol_js_1 = require("./protocol.js");
|
|
19
|
+
const session_crypto_js_1 = require("./session-crypto.js");
|
|
20
|
+
/** Default relay endpoint on the universal-link host (spec §17). */
|
|
21
|
+
exports.DEFAULT_RELAY_URL = `wss://${protocol_js_1.DEFAULT_LINK_HOST}/relay`;
|
|
22
|
+
/** Outgoing chunk threshold. The relay caps a whole frame at 1 MiB; this leaves room
|
|
23
|
+
* for the publish envelope around the payload. */
|
|
24
|
+
exports.RELAY_CHUNK_BYTES = 900000;
|
|
25
|
+
// Reassembly bounds (spec §16: enforce total and per-message ceilings so a peer cannot
|
|
26
|
+
// balloon our memory). 64 chunks x 900 KB covers the 32 MiB chain ceiling after base64.
|
|
27
|
+
const MAX_CHUNKS_PER_MESSAGE = 64;
|
|
28
|
+
const MAX_CONCURRENT_PARTIALS = 8;
|
|
29
|
+
const PARTIAL_STALE_MS = 120000;
|
|
30
|
+
function defaultWebSocketFactory(url) {
|
|
31
|
+
const globalWithWs = globalThis;
|
|
32
|
+
if (!globalWithWs.WebSocket) {
|
|
33
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.Disconnected, 'No WebSocket implementation available; pass options.webSocketFactory');
|
|
34
|
+
}
|
|
35
|
+
return new globalWithWs.WebSocket(url);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* {@link LinkTransport} over the Phantasma Link relay. `send` resolves once the relay
|
|
39
|
+
* acknowledged the publish (or rejects on timeout/close); incoming `deliver` frames are
|
|
40
|
+
* surfaced to the session client after chunk reassembly. Transient socket drops are
|
|
41
|
+
* absorbed by reconnection - the session layer only learns about an explicit close().
|
|
42
|
+
*/
|
|
43
|
+
class RelayTransport {
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.walletKeySeen = false;
|
|
46
|
+
this.pending = new Map();
|
|
47
|
+
this.partials = new Map();
|
|
48
|
+
this.publishSeq = 0;
|
|
49
|
+
this.reconnectAttempt = 0;
|
|
50
|
+
this.closed = false;
|
|
51
|
+
if (!options.topic) {
|
|
52
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidParams, 'Relay transport requires a topic');
|
|
53
|
+
}
|
|
54
|
+
this.topic = options.topic;
|
|
55
|
+
this.url = options.url ?? exports.DEFAULT_RELAY_URL;
|
|
56
|
+
this.factory = options.webSocketFactory ?? defaultWebSocketFactory;
|
|
57
|
+
this.maxPayloadBytes = options.maxPayloadBytes ?? exports.RELAY_CHUNK_BYTES;
|
|
58
|
+
this.maxAssembledBytes = options.maxAssembledBytes ?? 64 * 1024 * 1024;
|
|
59
|
+
this.publishAckTimeoutMs = options.publishAckTimeoutMs ?? 15000;
|
|
60
|
+
this.reconnectDelaysMs = options.reconnectDelaysMs ?? [500, 1000, 2000, 5000, 15000];
|
|
61
|
+
this.onWalletKey = options.onWalletKey;
|
|
62
|
+
this.log = options.log;
|
|
63
|
+
this.connect();
|
|
64
|
+
}
|
|
65
|
+
async send(frame) {
|
|
66
|
+
if (this.closed) {
|
|
67
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.Disconnected, 'Relay transport is closed');
|
|
68
|
+
}
|
|
69
|
+
if (frame.length <= this.maxPayloadBytes) {
|
|
70
|
+
await this.publish(frame);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Chunked send: split the opaque frame TEXT; the chunks travel as ordinary
|
|
74
|
+
// publishes and only the receiver reassembles (the relay stays blind).
|
|
75
|
+
const total = Math.ceil(frame.length / this.maxPayloadBytes);
|
|
76
|
+
if (total > MAX_CHUNKS_PER_MESSAGE) {
|
|
77
|
+
throw new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InvalidParams, 'Frame exceeds the relay transport size ceiling');
|
|
78
|
+
}
|
|
79
|
+
const msgId = (0, session_crypto_js_1.randomToken)();
|
|
80
|
+
const publishes = [];
|
|
81
|
+
for (let seq = 0; seq < total; seq++) {
|
|
82
|
+
const chunk = frame.slice(seq * this.maxPayloadBytes, (seq + 1) * this.maxPayloadBytes);
|
|
83
|
+
publishes.push(this.publish({ msgId, seq, total, chunk }));
|
|
84
|
+
}
|
|
85
|
+
await Promise.all(publishes);
|
|
86
|
+
}
|
|
87
|
+
onMessage(handler) {
|
|
88
|
+
this.messageHandler = handler;
|
|
89
|
+
}
|
|
90
|
+
onClose(handler) {
|
|
91
|
+
this.closeHandler = handler;
|
|
92
|
+
}
|
|
93
|
+
close() {
|
|
94
|
+
if (this.closed) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.closed = true;
|
|
98
|
+
if (this.reconnectTimer) {
|
|
99
|
+
clearTimeout(this.reconnectTimer);
|
|
100
|
+
}
|
|
101
|
+
this.rejectAllPending('Relay transport closed');
|
|
102
|
+
try {
|
|
103
|
+
this.socket?.close();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Closing an already-closed socket is harmless.
|
|
107
|
+
}
|
|
108
|
+
// The session layer learns about closure exactly once, and only for an explicit
|
|
109
|
+
// close - transient drops are hidden behind reconnection.
|
|
110
|
+
this.closeHandler?.('Relay transport closed');
|
|
111
|
+
}
|
|
112
|
+
// --- connection lifecycle -------------------------------------------------------
|
|
113
|
+
connect() {
|
|
114
|
+
const socket = this.factory(this.url);
|
|
115
|
+
this.socket = socket;
|
|
116
|
+
socket.onopen = () => {
|
|
117
|
+
if (this.closed) {
|
|
118
|
+
socket.close();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.reconnectAttempt = 0;
|
|
122
|
+
socket.send(JSON.stringify({ op: 'subscribe', topic: this.topic }));
|
|
123
|
+
// Re-send publishes the previous socket never got acked for (at-least-once;
|
|
124
|
+
// the wallet de-duplicates by envelope id). Order by publish sequence so a
|
|
125
|
+
// chunked message keeps its relative order.
|
|
126
|
+
const texts = [...this.pending.entries()]
|
|
127
|
+
.sort((a, b) => Number(a[0].slice(1)) - Number(b[0].slice(1)))
|
|
128
|
+
.map(([, entry]) => entry.text);
|
|
129
|
+
for (const text of texts) {
|
|
130
|
+
socket.send(text);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
socket.onmessage = (event) => {
|
|
134
|
+
if (typeof event.data === 'string') {
|
|
135
|
+
this.handleRaw(event.data);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
socket.onclose = () => {
|
|
139
|
+
if (!this.closed) {
|
|
140
|
+
this.scheduleReconnect();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// Errors surface as a subsequent close; nothing actionable here.
|
|
144
|
+
socket.onerror = () => { };
|
|
145
|
+
}
|
|
146
|
+
scheduleReconnect() {
|
|
147
|
+
const ladder = this.reconnectDelaysMs;
|
|
148
|
+
const delay = ladder[Math.min(this.reconnectAttempt, ladder.length - 1)];
|
|
149
|
+
this.reconnectAttempt += 1;
|
|
150
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
151
|
+
}
|
|
152
|
+
// --- outgoing -------------------------------------------------------------------
|
|
153
|
+
publish(payload) {
|
|
154
|
+
const id = `p${++this.publishSeq}`;
|
|
155
|
+
const text = JSON.stringify({ op: 'publish', topic: this.topic, id, payload });
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
// The ack timeout is the delivery guarantee's outer bound: it spans reconnect
|
|
158
|
+
// attempts (the entry survives them), so it must comfortably exceed the ladder.
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
this.pending.delete(id);
|
|
161
|
+
reject(new errors_js_1.LinkError(errors_js_1.LinkErrorCode.Disconnected, 'Relay did not acknowledge the publish'));
|
|
162
|
+
}, this.publishAckTimeoutMs);
|
|
163
|
+
this.pending.set(id, { resolve, reject, timer, text });
|
|
164
|
+
if (this.socket && this.socket.readyState === 1) {
|
|
165
|
+
this.socket.send(text);
|
|
166
|
+
}
|
|
167
|
+
// Not open: the publish stays pending and goes out in onopen's re-send pass.
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
rejectAllPending(reason) {
|
|
171
|
+
for (const [id, entry] of this.pending) {
|
|
172
|
+
clearTimeout(entry.timer);
|
|
173
|
+
entry.reject(new errors_js_1.LinkError(errors_js_1.LinkErrorCode.Disconnected, reason));
|
|
174
|
+
this.pending.delete(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// --- incoming -------------------------------------------------------------------
|
|
178
|
+
handleRaw(text) {
|
|
179
|
+
let frame;
|
|
180
|
+
try {
|
|
181
|
+
frame = JSON.parse(text);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return; // not a relay frame; ignore
|
|
185
|
+
}
|
|
186
|
+
switch (frame.op) {
|
|
187
|
+
case 'deliver': {
|
|
188
|
+
if (frame.topic !== this.topic) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const payload = frame.payload;
|
|
192
|
+
if (typeof payload === 'string') {
|
|
193
|
+
this.messageHandler?.(payload);
|
|
194
|
+
}
|
|
195
|
+
else if (payload && typeof payload === 'object') {
|
|
196
|
+
const record = payload;
|
|
197
|
+
// ecdh key hop (spec §18.1): the wallet's public key plus the first sealed
|
|
198
|
+
// envelope in one payload. The key callback runs FIRST so the session layer
|
|
199
|
+
// can already open the embedded frame; repeats are ignored (one hop per
|
|
200
|
+
// pairing - a second wpk on a live channel is noise or forgery).
|
|
201
|
+
if (typeof record.wpk === 'string') {
|
|
202
|
+
if (this.onWalletKey && !this.walletKeySeen) {
|
|
203
|
+
this.walletKeySeen = true;
|
|
204
|
+
this.onWalletKey(record.wpk);
|
|
205
|
+
if (typeof record.nonce === 'string' && typeof record.ct === 'string') {
|
|
206
|
+
this.messageHandler?.(JSON.stringify({ nonce: record.nonce, ct: record.ct }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.acceptChunk(record);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case 'ack': {
|
|
216
|
+
const entry = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined;
|
|
217
|
+
if (entry) {
|
|
218
|
+
this.pending.delete(frame.id);
|
|
219
|
+
clearTimeout(entry.timer);
|
|
220
|
+
entry.resolve();
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case 'error': {
|
|
225
|
+
const entry = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined;
|
|
226
|
+
const message = frame.message;
|
|
227
|
+
if (entry) {
|
|
228
|
+
// A publish-scoped error settles that one publish.
|
|
229
|
+
this.pending.delete(frame.id);
|
|
230
|
+
clearTimeout(entry.timer);
|
|
231
|
+
entry.reject(new errors_js_1.LinkError(errors_js_1.LinkErrorCode.InternalError, typeof message === 'string' ? message : 'Relay rejected the publish'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// No publish matches this error (e.g. a subscribe refusal like topic_limit, which carries
|
|
235
|
+
// no publish id). Spec §16 requires clients to surface error frames, not drop them - a
|
|
236
|
+
// silent drop is exactly what let a refused subscribe hang. We do NOT force a close here
|
|
237
|
+
// (a fatal error is followed by the server closing, which the reconnect path handles);
|
|
238
|
+
// surfacing keeps the failure visible instead of invisible.
|
|
239
|
+
const code = frame.code;
|
|
240
|
+
const detail = `relay error${typeof code !== 'undefined' ? ` code=${String(code)}` : ''}${typeof message === 'string' ? `: ${message}` : ''}`;
|
|
241
|
+
(this.log ?? ((m) => console.warn(`[phantasma-link relay] ${m}`)))(detail);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
return; // unknown server op; ignore for forward compatibility
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Collect one chunk; emit the reassembled frame when complete. Bounds: chunk count,
|
|
249
|
+
* total bytes, concurrent partial messages, and a staleness GC - a hostile peer can
|
|
250
|
+
* waste its own topic, but not this client's memory (spec §16). */
|
|
251
|
+
acceptChunk(raw) {
|
|
252
|
+
const msgId = raw.msgId;
|
|
253
|
+
const seq = raw.seq;
|
|
254
|
+
const total = raw.total;
|
|
255
|
+
const chunk = raw.chunk;
|
|
256
|
+
if (typeof msgId !== 'string' ||
|
|
257
|
+
typeof seq !== 'number' ||
|
|
258
|
+
typeof total !== 'number' ||
|
|
259
|
+
typeof chunk !== 'string' ||
|
|
260
|
+
!Number.isInteger(seq) ||
|
|
261
|
+
!Number.isInteger(total) ||
|
|
262
|
+
total < 1 ||
|
|
263
|
+
total > MAX_CHUNKS_PER_MESSAGE ||
|
|
264
|
+
seq < 0 ||
|
|
265
|
+
seq >= total) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Staleness GC runs lazily on arrival; an abandoned partial cannot linger forever.
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
for (const [key, partial] of this.partials) {
|
|
271
|
+
if (now - partial.touchedAt > PARTIAL_STALE_MS) {
|
|
272
|
+
this.partials.delete(key);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let partial = this.partials.get(msgId);
|
|
276
|
+
if (!partial) {
|
|
277
|
+
if (this.partials.size >= MAX_CONCURRENT_PARTIALS) {
|
|
278
|
+
return; // refuse new assemblies rather than grow without bound
|
|
279
|
+
}
|
|
280
|
+
partial = { total, received: new Map(), bytes: 0, touchedAt: now };
|
|
281
|
+
this.partials.set(msgId, partial);
|
|
282
|
+
}
|
|
283
|
+
if (partial.total !== total || partial.received.has(seq)) {
|
|
284
|
+
return; // inconsistent or duplicate chunk; ignore
|
|
285
|
+
}
|
|
286
|
+
partial.bytes += chunk.length;
|
|
287
|
+
if (partial.bytes > this.maxAssembledBytes) {
|
|
288
|
+
this.partials.delete(msgId);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
partial.received.set(seq, chunk);
|
|
292
|
+
partial.touchedAt = now;
|
|
293
|
+
if (partial.received.size === total) {
|
|
294
|
+
this.partials.delete(msgId);
|
|
295
|
+
let joined = '';
|
|
296
|
+
for (let i = 0; i < total; i++) {
|
|
297
|
+
joined += partial.received.get(i);
|
|
298
|
+
}
|
|
299
|
+
this.messageHandler?.(joined);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
exports.RelayTransport = RelayTransport;
|