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.
Files changed (75) hide show
  1. package/README.md +6 -0
  2. package/dist/cjs/index.js +3 -1
  3. package/dist/cjs/link/index.js +5 -0
  4. package/dist/cjs/link/v5/capabilities.js +6 -0
  5. package/dist/cjs/link/v5/client.js +324 -0
  6. package/dist/cjs/link/v5/deeplink.js +123 -0
  7. package/dist/cjs/link/v5/encoding.js +95 -0
  8. package/dist/cjs/link/v5/envelope.js +73 -0
  9. package/dist/cjs/link/v5/errors.js +60 -0
  10. package/dist/cjs/link/v5/index.js +33 -0
  11. package/dist/cjs/link/v5/loopback-transport.js +70 -0
  12. package/dist/cjs/link/v5/methods.js +4 -0
  13. package/dist/cjs/link/v5/pairing.js +95 -0
  14. package/dist/cjs/link/v5/protocol.js +61 -0
  15. package/dist/cjs/link/v5/relay-transport.js +303 -0
  16. package/dist/cjs/link/v5/session-crypto.js +120 -0
  17. package/dist/cjs/link/v5/transport.js +208 -0
  18. package/dist/cjs/link/v5/web-deeplink.js +141 -0
  19. package/dist/cjs/public.js +37 -1
  20. package/dist/esm/index.js +2 -0
  21. package/dist/esm/link/index.js +5 -0
  22. package/dist/esm/link/v5/capabilities.js +5 -0
  23. package/dist/esm/link/v5/client.js +320 -0
  24. package/dist/esm/link/v5/deeplink.js +115 -0
  25. package/dist/esm/link/v5/encoding.js +87 -0
  26. package/dist/esm/link/v5/envelope.js +65 -0
  27. package/dist/esm/link/v5/errors.js +56 -0
  28. package/dist/esm/link/v5/index.js +17 -0
  29. package/dist/esm/link/v5/loopback-transport.js +66 -0
  30. package/dist/esm/link/v5/methods.js +3 -0
  31. package/dist/esm/link/v5/pairing.js +91 -0
  32. package/dist/esm/link/v5/protocol.js +58 -0
  33. package/dist/esm/link/v5/relay-transport.js +299 -0
  34. package/dist/esm/link/v5/session-crypto.js +104 -0
  35. package/dist/esm/link/v5/transport.js +204 -0
  36. package/dist/esm/link/v5/web-deeplink.js +133 -0
  37. package/dist/esm/public.js +3 -0
  38. package/dist/types/index.d.ts +1 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/link/v5/capabilities.d.ts +80 -0
  41. package/dist/types/link/v5/capabilities.d.ts.map +1 -0
  42. package/dist/types/link/v5/client.d.ts +119 -0
  43. package/dist/types/link/v5/client.d.ts.map +1 -0
  44. package/dist/types/link/v5/deeplink.d.ts +52 -0
  45. package/dist/types/link/v5/deeplink.d.ts.map +1 -0
  46. package/dist/types/link/v5/encoding.d.ts +15 -0
  47. package/dist/types/link/v5/encoding.d.ts.map +1 -0
  48. package/dist/types/link/v5/envelope.d.ts +48 -0
  49. package/dist/types/link/v5/envelope.d.ts.map +1 -0
  50. package/dist/types/link/v5/errors.d.ts +39 -0
  51. package/dist/types/link/v5/errors.d.ts.map +1 -0
  52. package/dist/types/link/v5/index.d.ts +15 -0
  53. package/dist/types/link/v5/index.d.ts.map +1 -0
  54. package/dist/types/link/v5/loopback-transport.d.ts +43 -0
  55. package/dist/types/link/v5/loopback-transport.d.ts.map +1 -0
  56. package/dist/types/link/v5/methods.d.ts +83 -0
  57. package/dist/types/link/v5/methods.d.ts.map +1 -0
  58. package/dist/types/link/v5/pairing.d.ts +37 -0
  59. package/dist/types/link/v5/pairing.d.ts.map +1 -0
  60. package/dist/types/link/v5/protocol.d.ts +60 -0
  61. package/dist/types/link/v5/protocol.d.ts.map +1 -0
  62. package/dist/types/link/v5/relay-transport.d.ts +73 -0
  63. package/dist/types/link/v5/relay-transport.d.ts.map +1 -0
  64. package/dist/types/link/v5/session-crypto.d.ts +51 -0
  65. package/dist/types/link/v5/session-crypto.d.ts.map +1 -0
  66. package/dist/types/link/v5/transport.d.ts +77 -0
  67. package/dist/types/link/v5/transport.d.ts.map +1 -0
  68. package/dist/types/link/v5/web-deeplink.d.ts +77 -0
  69. package/dist/types/link/v5/web-deeplink.d.ts.map +1 -0
  70. package/dist/types/public.d.ts +1 -0
  71. package/dist/types/public.d.ts.map +1 -1
  72. package/examples/web-deeplink-dapp.ts +53 -0
  73. package/package.json +8 -2
  74. package/spec/CHANGELOG.md +9 -0
  75. 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,4 @@
1
+ "use strict";
2
+ // Phantasma Link v5 - typed params/results for every `pha_*` method and event (spec §9.5).
3
+ // Binary fields (tx bytes, scripts, signatures, messages) are base64 strings.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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;