diff --git a/apps/host/eslint.config.js b/apps/host/eslint.config.js index 46d277e7..bb4d0373 100644 --- a/apps/host/eslint.config.js +++ b/apps/host/eslint.config.js @@ -11,5 +11,29 @@ export default [ tsconfigRootDir: import.meta.dirname, }, }, + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@dotli/protocol/broker", + message: + "Host code must open chains through @dotli/protocol/client.", + }, + { + name: "@dotli/resolver/chains", + message: + "Smoldot upstream ownership belongs to the protocol runtime.", + }, + { + name: "@dotli/resolver/rpc-chain", + message: + "RPC upstream ownership belongs to the protocol runtime.", + }, + ], + }, + ], + }, }, ]; diff --git a/apps/host/src/errors.ts b/apps/host/src/errors.ts index 4bdd667d..fffa0a1f 100644 --- a/apps/host/src/errors.ts +++ b/apps/host/src/errors.ts @@ -44,6 +44,12 @@ export interface ErrorDescription { export function describeError(err: unknown, isP2p: boolean): ErrorDescription { const msg = err instanceof Error ? err.message : String(err); + // Chunk-load failures want a reload prompt regardless of which side of the + // protocol boundary they surface on. The iframe reports them as `fatal` + // (its vite:preloadError relay), so this must run before the fatal branch. + if (msg.includes("Failed to fetch dynamically imported module")) { + return { message: HOST_ERRORS.MODULE_FETCH_FAILED, recovery: "reload" }; + } if (err instanceof ProtocolFatalError) { return { message: HOST_ERRORS.FATAL_PANIC, recovery: "switch-backend" }; } @@ -56,9 +62,6 @@ export function describeError(err: unknown, isP2p: boolean): ErrorDescription { recovery: "switch-backend", }; } - if (msg.includes("Failed to fetch dynamically imported module")) { - return { message: HOST_ERRORS.MODULE_FETCH_FAILED, recovery: "reload" }; - } if ( msg.includes("non-IPFS contenthash") || msg.includes("Failed to decode contenthash") diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index 9622cee9..595ff187 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -1290,7 +1290,8 @@ async function main(): Promise { }); try { - const { statusToPhase } = await import("@dotli/resolver/resolve"); + const { statusToPhase } = + await import("@dotli/resolver/access-raw-storage"); const onResolveProgress = (msg: string): void => { // Progress events arrive as opaque strings across the iframe // boundary. The resolver package owns the authoritative diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index 3967c750..3c78a142 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -66,8 +66,8 @@ import { // these either. Smoldot for shared-worker mode lives inside // `./protocol-shared-worker.ts`, which is already a separate bundle. import { - createRpcChainProvider, - isRpcChainSupported, + createRpcUpstreamProvider, + isRpcUpstreamSupported, } from "@dotli/resolver/rpc-chain"; import { log } from "@dotli/shared/log"; import { serializeError } from "@dotli/shared/errors"; @@ -93,6 +93,10 @@ import { type ProtocolRequestEnvelope, type ProtocolRequestMap, } from "@dotli/protocol/messages"; +import { + ChainConnectionError, + toProtocolErrorPayload, +} from "@dotli/protocol/errors"; import type { SWRelayRequest, SWOutbound } from "./protocol-shared-worker"; initSentry("host"); @@ -546,6 +550,75 @@ function signalError(message: string): void { async function initSharedWorkerMode(network: Network): Promise { const swStartTime = performance.now(); + const localPeopleConnectionIds = new Set(); + let localPeopleEnginePromise: Promise | null = null; + + function getLocalPeopleEngine(): Promise { + localPeopleEnginePromise ??= Promise.all([ + import("@dotli/resolver/chains"), + import("@dotli/resolver/smoldot"), + ]).then(([chains, smoldot]) => { + // WebRTC is unavailable in SharedWorkerGlobalScope. Statement Store + // peers can be discovered through WebRTC even when chain sync itself + // succeeds over WSS, so People must keep smoldot's browser networking + // frontend in this protocol iframe. The broker and physical provider + // still remain inside the protocol runtime; only cross-tab sharing is + // unavailable for this chain. + const peopleGenesis = + getActiveServicesConfig().people.genesis.toLowerCase(); + const engine = createEngine({ + createUpstreamProvider: (genesisHash) => + genesisHash.toLowerCase() === peopleGenesis + ? chains.createSmoldotUpstreamProvider(genesisHash) + : null, + isChainSupported: (genesisHash) => + genesisHash.toLowerCase() === peopleGenesis, + onInit: () => { + smoldot.getSmoldot(); + }, + onCleanup: () => { + smoldot.terminateSmoldot(); + }, + }); + smoldot.onSmoldotFatal((message) => { + log.error( + "[dot.li protocol] Local People smoldot panic detected, signaling fatal", + ); + if (window.parent !== window) { + window.parent.postMessage( + { + namespace: "dotli:protocol", + kind: "fatal", + message, + }, + "*", + ); + } + }); + return engine; + }); + return localPeopleEnginePromise; + } + + function isLocalPeopleRequest(request: ProtocolRequestEnvelope): boolean { + if (request.method === "chainConnect") { + const payload = request.payload as ProtocolRequestMap["chainConnect"]; + return ( + typeof payload.genesisHash === "string" && + payload.genesisHash.toLowerCase() === + getActiveServicesConfig().people.genesis.toLowerCase() + ); + } + if (request.method === "chainSend") { + const payload = request.payload as ProtocolRequestMap["chainSend"]; + return localPeopleConnectionIds.has(payload.connectionId); + } + if (request.method === "chainDisconnect") { + const payload = request.payload as ProtocolRequestMap["chainDisconnect"]; + return localPeopleConnectionIds.has(payload.connectionId); + } + return false; + } // Vite statically rewrites `new SharedWorker(new URL("./worker.ts", // import.meta.url), ...)` to point at the bundled chunk. The `new URL` @@ -626,12 +699,49 @@ async function initSharedWorkerMode(network: Network): Promise { return; } - const msg: SWRelayRequest = { + if (isLocalPeopleRequest(data)) { + const payload = data.payload as { connectionId?: unknown }; + const connectionId = + typeof payload.connectionId === "string" ? payload.connectionId : null; + if (data.method === "chainConnect" && connectionId !== null) { + localPeopleConnectionIds.add(connectionId); + } + void getLocalPeopleEngine() + .then((engine) => + engine.handleRequest(data, event.origin, (response) => { + postToSource(event.source, event.origin, response); + }), + ) + .then(() => { + if (data.method === "chainDisconnect" && connectionId !== null) { + localPeopleConnectionIds.delete(connectionId); + } + }) + .catch((error: unknown) => { + if ( + connectionId !== null && + (data.method === "chainConnect" || + data.method === "chainDisconnect") + ) { + localPeopleConnectionIds.delete(connectionId); + } + log.error("[dot.li protocol] Local People request failed:", error); + postToSource(event.source, event.origin, { + namespace: "dotli:protocol", + kind: "response", + id: data.id, + ok: false, + ...toProtocolErrorPayload(error), + }); + }); + return; + } + + port.postMessage({ type: "relay-request", envelope: data, origin: event.origin, - }; - port.postMessage(msg); + } satisfies SWRelayRequest); }); // Relay SharedWorker responses back up to the parent. @@ -655,6 +765,9 @@ async function initSharedWorkerMode(network: Network): Promise { /* port already closed on unload, safe */ } port.close(); + void localPeopleEnginePromise?.then((engine) => { + engine.cleanup(); + }); }); } @@ -666,12 +779,15 @@ async function initDirectMode(): Promise { // Dynamic imports so users in `rpc` or `shared-worker` submode don't pay // the smoldot / chain-specs bundle cost (D-1). - const [{ createChainProvider, isChainSupported }, resolve, smoldotMod] = - await Promise.all([ - import("@dotli/resolver/chains"), - import("@dotli/resolver/resolve"), - import("@dotli/resolver/smoldot"), - ]); + const [ + { createSmoldotUpstreamProvider, isChainSupported }, + resolve, + smoldotMod, + ] = await Promise.all([ + import("@dotli/resolver/chains"), + import("@dotli/resolver/resolve"), + import("@dotli/resolver/smoldot"), + ]); const { getRelayChain, getSmoldot, @@ -703,7 +819,7 @@ async function initDirectMode(): Promise { }); const engine = createEngine({ - createChainProvider, + createUpstreamProvider: createSmoldotUpstreamProvider, isChainSupported, onBrokerReady: (broker) => { // Route the resolver's Asset Hub reads AND the People warm-keep through @@ -770,8 +886,8 @@ function initRpcMode(): void { ); const engine = createEngine({ - createChainProvider: createRpcChainProvider, - isChainSupported: isRpcChainSupported, + createUpstreamProvider: createRpcUpstreamProvider, + isChainSupported: isRpcUpstreamSupported, // No onInit / onCleanup: the WS provider lifecycle is owned by the // broker's `ensureUpstream` / `disconnectAll`. // No resolver: gateway-mode resolution doesn't go through this iframe. @@ -815,7 +931,7 @@ function bindEngineToMessages(engine: ProtocolEngine): void { kind: "response", id: data.id, ok: false, - error: serializeError(error), + ...toProtocolErrorPayload(error), }); }); }); @@ -1037,7 +1153,7 @@ interface ProtocolEngine { interface EngineOptions { /** Factory for a `JsonRpcProvider` keyed by genesis hash. */ - createChainProvider: (genesisHash: string) => JsonRpcProvider | null; + createUpstreamProvider: (genesisHash: string) => JsonRpcProvider | null; /** Whether the given genesis hash is handled by this engine. */ isChainSupported: (genesisHash: string) => boolean; /** Called once at engine creation, e.g. to kick off smoldot pre-sync. */ @@ -1077,7 +1193,7 @@ function createEngine(options: EngineOptions): ProtocolEngine { const MAX_CONNS = 10; const connections = new Map(); const originConns = new Map>(); - const broker = createChainBrokerManager(options.createChainProvider); + const broker = createChainBrokerManager(options.createUpstreamProvider); options.onBrokerReady?.(broker); options.onInit?.(); @@ -1225,22 +1341,36 @@ function createEngine(options: EngineOptions): ProtocolEngine { ); } if (!options.isChainSupported(payload.genesisHash)) { - throw new Error(`Unsupported chain: ${payload.genesisHash}`); + throw new ChainConnectionError( + "UNSUPPORTED_CHAIN", + `Unsupported chain: ${payload.genesisHash}`, + ); + } + let connection: StringJsonRpcConnection | null; + try { + connection = broker.connectRemote( + payload.genesisHash, + payload.connectionId, + (message) => { + respond({ + namespace: "dotli:protocol", + kind: "chain-message", + connectionId: payload.connectionId, + message, + }); + }, + ); + } catch (error: unknown) { + throw new ChainConnectionError( + "UPSTREAM_CONNECTION_FAILED", + serializeError(error), + ); } - const connection = broker.connectRemote( - payload.genesisHash, - payload.connectionId, - (message) => { - respond({ - namespace: "dotli:protocol", - kind: "chain-message", - connectionId: payload.connectionId, - message, - }); - }, - ); if (connection === null) { - throw new Error("Failed to create chain broker"); + throw new ChainConnectionError( + "UPSTREAM_CONNECTION_FAILED", + "Failed to create chain broker", + ); } connections.set(payload.connectionId, connection); oc.add(payload.connectionId); diff --git a/apps/protocol/src/protocol-shared-worker.ts b/apps/protocol/src/protocol-shared-worker.ts index 9f8bbd6d..97de9ac9 100644 --- a/apps/protocol/src/protocol-shared-worker.ts +++ b/apps/protocol/src/protocol-shared-worker.ts @@ -20,7 +20,10 @@ import { setNetworkOverride, getActiveServicesConfig, } from "@dotli/config/network"; -import { createChainProvider, isChainSupported } from "@dotli/resolver/chains"; +import { + createSmoldotUpstreamProvider, + isChainSupported, +} from "@dotli/resolver/chains"; import { getRelayChain, getSmoldotDirect, @@ -29,9 +32,7 @@ import { resolveOwner, resolveRootManifest, setResolverAssetHubProvider, - setResolverPeopleProvider, waitForAssetHubFinalized, - waitForPeopleFinalized, } from "@dotli/resolver/resolve"; import { onSmoldotFatal } from "@dotli/resolver/smoldot"; import { m } from "@dotli/metrics/metrics"; @@ -43,6 +44,11 @@ import { } from "@dotli/protocol/broker"; import { serializeError } from "@dotli/shared/errors"; import { isExecutableKind } from "@dotli/shared/executables"; +import { + ChainConnectionError, + toProtocolErrorPayload, +} from "@dotli/protocol/errors"; +import { log } from "@dotli/shared/log"; initSentry("worker"); installGlobalErrorHandlers("worker"); @@ -85,7 +91,7 @@ export type SWOutbound = SWRelayResponse | SWReady | SWError; const TAG = "[dot.li SW]"; function swLog(...args: unknown[]): void { - console.warn(TAG, ...args); + log.debug(TAG, ...args); } function swError(...args: unknown[]): void { @@ -183,7 +189,9 @@ async function presync(): Promise { // through it as a local session, so there is one shared Asset Hub follow // (never removed mid-read) instead of a separate resolver chain the first // dApp connection would release — the `ChainHead disjointed` load failure. - chainBrokerManager = createChainBrokerManager(createChainProvider); + chainBrokerManager = createChainBrokerManager( + createSmoldotUpstreamProvider, + ); setResolverAssetHubProvider(() => requireBrokerLocalProvider( chainBrokerManager, @@ -191,18 +199,6 @@ async function presync(): Promise { "Asset Hub", ), ); - // The People warm-keep must share this same broker follow. A separate - // getSmProvider on the People chain would race the broker's follow (one - // shared smoldot JSON-RPC queue) and have its events misrouted, so the - // broker drops People follow events as "unknown token" and reads hang. - setResolverPeopleProvider(() => - requireBrokerLocalProvider( - chainBrokerManager, - getActiveServicesConfig().people.genesis, - "People", - ), - ); - // 4. Wait for Asset Hub to sync to a finalized block via the // explicit presync primitive (no more overloading `resolveDotName` // with a sentinel label). This now syncs the broker's shared chain. @@ -225,33 +221,6 @@ async function presync(): Promise { port.postMessage(readyMsg); } pendingPorts.length = 0; - - // Warm the People chain in the background. Legacy-account auth reads the - // username -> account map on People, and on a cold start that read races - // the parachain warp sync (the source of the intermittent failures). Start - // syncing it now so it is ready by the time auth runs. People is not needed - // for resolution, so this must not gate the ready signal above. - swLog("Warming People chain in background..."); - // Route the People warm-up through the broker's shared follow (mirrors - // Asset Hub above) so it doesn't open a second competing smoldot follow. - setResolverPeopleProvider(() => - requireBrokerLocalProvider( - chainBrokerManager, - getActiveServicesConfig().people.genesis, - "People", - ), - ); - void waitForPeopleFinalized((msg) => { - swLog(`People warm status: ${msg}`); - }) - .then(() => { - swLog("People chain warmed"); - }) - .catch((err: unknown) => { - swLog( - `People chain warm failed (retried on demand): ${serializeError(err)}`, - ); - }); } catch (err: unknown) { const msg = serializeError(err); swError(`Pre-sync failed: ${msg}`); @@ -460,32 +429,39 @@ async function handleRequest( ); } if (!isChainSupported(payload.genesisHash)) { - throw new Error(`Unsupported chain: ${payload.genesisHash}`); + throw new ChainConnectionError( + "UNSUPPORTED_CHAIN", + `Unsupported chain: ${payload.genesisHash}`, + ); } // The resolver and all dApp sessions share one Asset Hub chain via the // broker, so there is no resolver chain to release here; connect // directly. - let chainMsgCount = 0; - const connection = chainBrokerManager.connectRemote( - payload.genesisHash, - payload.connectionId, - (message) => { - chainMsgCount++; - if (chainMsgCount <= 5 || chainMsgCount % 100 === 0) { - swLog( - `Chain message #${String(chainMsgCount)} for ${payload.connectionId} (${String(message.length)} bytes)`, - ); - } - sendToPort(port, { - namespace: "dotli:protocol", - kind: "chain-message", - connectionId: payload.connectionId, - message, - }); - }, - ); + let connection: StringJsonRpcConnection | null; + try { + connection = chainBrokerManager.connectRemote( + payload.genesisHash, + payload.connectionId, + (message) => { + sendToPort(port, { + namespace: "dotli:protocol", + kind: "chain-message", + connectionId: payload.connectionId, + message, + }); + }, + ); + } catch (error: unknown) { + throw new ChainConnectionError( + "UPSTREAM_CONNECTION_FAILED", + serializeError(error), + ); + } if (connection === null) { - throw new Error("Failed to create chain broker"); + throw new ChainConnectionError( + "UPSTREAM_CONNECTION_FAILED", + "Failed to create chain broker", + ); } chainConnections.set(payload.connectionId, connection); connectionPorts.set(payload.connectionId, port); @@ -604,7 +580,7 @@ self.addEventListener("connect", (event) => { kind: "response", id: envelope.id, ok: false, - error: msg, + ...toProtocolErrorPayload(error), }); }); }); diff --git a/docs/smoldot.md b/docs/smoldot.md index ebdab5f4..7b4724c0 100644 --- a/docs/smoldot.md +++ b/docs/smoldot.md @@ -12,38 +12,39 @@ dotli embeds the [smoldot](https://github.com/smol-dot/smoldot) Polkadot light c ## Where smoldot lives -The protocol iframe owns the resolver and sandbox-facing smoldot client. When -the host backend is set to Light Client, the host shell also starts a smoldot -client for Rust-core `chain.connect` requests. +The protocol runtime owns every physical smoldot client. Host code, including +the Rust Core chain callback, opens logical connections through +`connectChain()` and cannot construct a smoldot provider directly. | Origin | Purpose | Triggered by | |---|---|---| -| Protocol iframe (`host.localhost`, production `paseo.li`) | Domain resolution (Asset Hub query to CID), chain RPC brokering, bitswap content fetching | `apps/protocol/src/main.ts` (direct/shared-worker submodes) and `apps/protocol/src/protocol-shared-worker.ts` | -| Host shell (user's destination domain, e.g. `foo.dot`) | Rust-core chain access for auth, product requests, and Bulletin submission when Light Client is selected | `packages/ui/src/host-callbacks/Chain.ts` | +| Protocol iframe (`host.localhost`, production `paseo.li`) | Direct-mode smoldot, RPC brokering, and the People provider used by SharedWorker mode | `apps/protocol/src/main.ts` | +| Protocol SharedWorker | Cross-tab smoldot and brokering for Asset Hub, Bulletin, and relay-chain connections | `apps/protocol/src/protocol-shared-worker.ts` | -Both origins construct smoldot through the singletons in -`packages/resolver/src/smoldot.ts`. RPC Gateway mode routes Rust-core requests -to configured WebSocket endpoints and does not start the host-shell client. +Both protocol contexts construct smoldot through their realm-local singleton +in `packages/resolver/src/smoldot.ts`. RPC Gateway mode uses configured +WebSocket endpoints and does not start smoldot. ## Smoldot factories -Two factories live in `packages/resolver/src/smoldot.ts`: +Two client factories live in `packages/resolver/src/smoldot.ts`: -- `getSmoldot()` (line 171) calls `startFromWorker(new SmWorker(), …)`. Smoldot runs in a dedicated Web Worker. Used in iframe main-thread contexts. -- `getSmoldotDirect()` (line 158) calls `start(…)`. Smoldot runs on the calling thread. Used inside the SharedWorker, where the `Worker` constructor is unavailable. +- `getSmoldot()` calls `startFromWorker(new SmWorker(), …)`. Smoldot runs in a dedicated Web Worker while its browser networking frontend remains in the protocol iframe. +- `getSmoldotDirect()` calls `start(…)`. Smoldot runs on the calling thread. It is used inside the SharedWorker, where the `Worker` constructor is unavailable. -Both share the same `smoldotInstance` cell (line 148). Calling either returns the existing client if one is already constructed. +Both share the same `smoldotInstance` cell within a JavaScript realm. Calling +either returns the existing client if one is already constructed. ## Chains -Five chain factories ship in `packages/resolver/src/smoldot.ts`. +Four chain factories ship in `packages/resolver/src/smoldot.ts`. | Function | Chain | Purpose | Genesis hash | |---|---|---|---| -| `getRelayChain()` | Paseo relay | Required parent for the parachains below | `PASEO_RELAY_GENESIS` (`config.ts:83`) | | `getDappAssetHubChain()` | Asset Hub Paseo | Domain resolution and product queries (single shared chain) | `ASSET_HUB_PASEO_GENESIS` (`config.ts:85`) | | `getBulletinChain()` | Bulletin Paseo | Content reads and Rust-core preimage submission | active network config | | `getPeopleChain()` | People | Statement-store auth | active network config | +| `getRelayChain()` | Paseo relay | Required parent for the parachains above | `PASEO_RELAY_GENESIS` (`config.ts:83`) | There is exactly one Asset Hub chain (`getDappAssetHubChain()`, `smoldot.ts:519`), shared by the resolver and every dApp session through the `ChainBroker`. The broker opens a single follow that is never removed mid-read. The resolver reads through a local broker session (`broker.getLocalProvider(genesis)`, object-wire), and dApp connections attach as remote sessions on the same follow. This replaced the earlier resolver/product chain split. In that split the resolver's chain was released once the CID was cached, so the first dApp connection releasing that follow mid-read produced the `ChainHead disjointed` load failure. @@ -54,7 +55,7 @@ Hub, Bulletin, and People chains. The protocol iframe parses a `?mode=` URL parameter (`apps/protocol/src/main.ts:318`) and dispatches at `main.ts:443-466`. -- `?mode=shared-worker` opens a `SharedWorker` (`apps/protocol/src/protocol-shared-worker.ts`). Smoldot runs in the worker thread. +- `?mode=shared-worker` opens a `SharedWorker` (`apps/protocol/src/protocol-shared-worker.ts`). Asset Hub, Bulletin, and relay-chain providers run there and can be shared across tabs. People runs in the protocol iframe because `RTCPeerConnection` is unavailable in `SharedWorkerGlobalScope`; chain sync can succeed over WSS while Statement Store peer discovery still needs WebRTC. - `?mode=direct` runs `initDirectMode()` (`main.ts:593`), which dynamic-imports the resolver and runs smoldot on the iframe main thread. - `?mode=rpc` runs `initRpcMode()` (`main.ts:658`). No smoldot. Chain calls go to a trusted WSS JSON-RPC endpoint. @@ -62,23 +63,20 @@ The host shell selects the submode from `chainBackend` at `apps/host/src/main.ts ## Talking to a chain -Sandbox-facing consumers use the cross-origin seam exposed by -`@dotli/protocol/client`. The host's Rust-core `chain.connect` callback is the -exception: it imports `@dotli/resolver/chains` and -`@dotli/resolver/rpc-chain` to honor the selected backend. +Every host consumer uses the cross-origin seam exposed by +`@dotli/protocol/client`. ```ts import { createRemoteChainProvider } from "@dotli/protocol/client"; import { ASSET_HUB_PASEO_GENESIS } from "@dotli/config/config"; const provider = createRemoteChainProvider(ASSET_HUB_PASEO_GENESIS); -if (provider === null) { - throw new Error("Chain not in SUPPORTED_GENESIS_HASHES"); -} const client = createClient(provider); // polkadot-api ``` -`createRemoteChainProvider(genesisHash)` (`packages/protocol/src/client.ts:619`) returns a polkadot-api `JsonRpcProvider` that bridges to the protocol iframe via `chainConnect` / `chainSend` / `chainDisconnect` postMessage envelopes. The protocol iframe's smoldot is the actual backend. Returns `null` if the genesis hash is not in `SUPPORTED_GENESIS_HASHES`. +`connectChain(genesisHash)` (`packages/protocol/src/client.ts`) opens an asynchronous string-wire connection through the protocol iframe via `chainConnect` / `chainSend` / `chainDisconnect` envelopes. The protocol iframe or SharedWorker owns the broker and physical upstream provider. Rust Core's host callback uses this API directly. + +`createRemoteChainProvider(genesisHash)` is the PAPI compatibility adapter over `connectChain()`. It converts PAPI's synchronous object-wire provider contract to the asynchronous string-wire connection without creating another broker or physical provider. Resolution helpers are pre-built: `resolveDotNameRemote(label)` and `resolveOwnerRemote(label)` at `client.ts:525` and `client.ts:537`. Call these instead of the resolver's local equivalents. @@ -86,7 +84,8 @@ Bulletin preimage submission is built, signed, and submitted entirely by the Rus ## Persistence -Smoldot persists chain DBs to IndexedDB internally. dotli does not manage save/load. The comment at `smoldot.ts:8-9` is explicit on this. +dotli periodically extracts smoldot chain databases and persists them to +IndexedDB through `packages/resolver/src/smoldot-db.ts`. Pre-cutover host-side smoldot may have left an IndexedDB chain DB at the user's destination origin. Stale state from the deleted code path stays on disk until the user clears storage. There is no `dotli doctor` command for this today. @@ -100,11 +99,11 @@ Pre-cutover host-side smoldot may have left an IndexedDB chain DB at the user's ## Owner-only APIs These resolver-package exports are owner-only and must not be imported outside -`apps/protocol/`, except for the host chain callback described above: +`apps/protocol/`: -- `smoldot.ts`: `getSmoldot`, `getSmoldotDirect`, `terminateSmoldot`, `onSmoldotFatal`, `onConnectionIssue`, `getRelayChain`, `getBulletinChain`, `getPeopleChain`, `getDappAssetHubChain`, `getDappAssetHubProvider`, `makeNonRemovingChain`, `getPeopleChainProvider` -- `chains.ts`: `createChainProvider`, `isChainSupported` (host chain callback only) +- `chains.ts`: `createSmoldotUpstreamProvider`, `isChainSupported` - `resolve.ts` re-exports of `getSmoldot`, `getSmoldotDirect`, `getRelayChain`, `onConnectionIssue` plus the chain-touching helpers `resolveDotName`, `resolveOwner`, `waitForAssetHubFinalized`, `destroyResolverClient`, and `setResolverAssetHubProvider` (the bootstrap seam that points the resolver's Asset Hub reads at the broker's local session) +- `smoldot.ts`: `getSmoldot`, `getSmoldotDirect`, `terminateSmoldot`, `onSmoldotFatal`, `onConnectionIssue`, `getRelayChain`, `getBulletinChain`, `getPeopleChain`, `getDappAssetHubChain`, `getDappAssetHubProvider`, `makeNonRemovingChain` ## Adding a new chain @@ -114,11 +113,11 @@ Steps to make a parachain reachable through the protocol iframe. The sequence be 2. Add a loader in `packages/resolver/src/chain-specs/index.ts`. Mirror `getBulletinPaseoChainSpec`. 3. Add a `getChain()` factory in `packages/resolver/src/smoldot.ts`. Mirror `getBulletinChain`. Set `potentialRelayChains` correctly. 4. Add the chain's genesis hash as a `0x…` constant in `packages/config/src/config.ts`. Include it in `SUPPORTED_GENESIS_HASHES`. -5. Wire the factory into `createChainProvider` in `packages/resolver/src/chains.ts` so the protocol iframe routes the genesis hash to the new chain. -6. Sandbox consumers call `createRemoteChainProvider()` from `@dotli/protocol/client`; Rust-core access uses the host `chain.connect` callback. +5. Wire the factory into `createSmoldotUpstreamProvider` in `packages/resolver/src/chains.ts` so the protocol runtime routes the genesis hash to the new chain. +6. Raw string-wire consumers call `connectChain()`. PAPI consumers use `createRemoteChainProvider()`. Rust Core uses `connectChain()` through the host callback. -Steps 4 and 5 make a chain reachable from both the protocol broker and the -host callback. Skip them and the request fails with `"Unsupported chain"`. +Steps 4 and 5 make a chain reachable through the protocol broker. The request +fails with `UNSUPPORTED_CHAIN` when the active backend cannot serve it. ## Related diff --git a/packages/config/src/network.ts b/packages/config/src/network.ts index 26258fbb..ae13b0b4 100644 --- a/packages/config/src/network.ts +++ b/packages/config/src/network.ts @@ -272,12 +272,12 @@ export function getActiveSupportedGenesisHashes(): Set { * Chains advertised to sandboxed dApps in **RPC-gateway** mode: the curated * system chains that have configured WSS RPC endpoints. The Bulletin chain is * intentionally excluded because its content is served through IPFS gateways. - * This list controls feature advertisement, not access control: the shared - * Rust-core connection callback also serves core-owned Bulletin operations. + * This list controls feature advertisement, not access control: the protocol + * runtime also serves core-owned Bulletin operations. * - * Single source of truth shared by the host's chain-support advertisement - * (`isRemoteChainSupported`) and the gateway provider factory - * (`createRpcChainProvider`). + * Single source of truth for product-facing gateway feature support. The + * protocol runtime uses `getActiveCoreGatewayChains()` for its wider + * operational connection set. */ export function getActiveGatewayChains(): ChainService[] { const cfg = getActiveServicesConfig(); @@ -289,7 +289,7 @@ export function getActiveGatewaySupportedGenesisHashes(): Set { return new Set(getActiveGatewayChains().map((c) => c.genesis.toLowerCase())); } -/** Gateway chains accepted by the shared Rust-core connection callback. */ +/** Gateway chains the protocol runtime can open an upstream connection to. */ export function getActiveCoreGatewayChains(): ChainService[] { const cfg = getActiveServicesConfig(); return [...getActiveGatewayChains(), cfg.bulletin].filter( diff --git a/packages/config/tests/gateway-chains.test.ts b/packages/config/tests/gateway-chains.test.ts index 46552913..5ecef60f 100644 --- a/packages/config/tests/gateway-chains.test.ts +++ b/packages/config/tests/gateway-chains.test.ts @@ -14,9 +14,8 @@ import { const v2 = NETWORK_NAME_TO_SERVICES_CONFIG[NetworkName.PASEO_NEXT_V2]; -// The gateway set drives the host's chain-support advertisement in -// rpc-gateway mode (`isRemoteChainSupported`). The core connection callback -// accepts a wider set because it also carries host-owned Bulletin operations. +// Product feature support uses the curated gateway set. The protocol runtime +// accepts a wider operational set because Rust Core also uses Bulletin. describe("gateway-supported chains (rpc-gateway mode)", () => { beforeEach(() => { setNetworkOverride(NetworkName.PASEO_NEXT_V2); diff --git a/packages/protocol/src/broker.ts b/packages/protocol/src/broker.ts index c450a140..5d606a02 100644 --- a/packages/protocol/src/broker.ts +++ b/packages/protocol/src/broker.ts @@ -6,6 +6,7 @@ import type { JsonRpcProvider, JsonRpcRequest as UpstreamJsonRpcRequest, } from "@polkadot-api/json-rpc-provider"; +import { log } from "@dotli/shared/log"; /** * String-wire variant of `JsonRpcConnection` exposed by `connectRemote`. @@ -116,6 +117,13 @@ const RELEASE_METHODS = new Set(TOKEN_METHODS.values()); const MAX_EARLY_SUBSCRIPTION_TOKENS = 32; const MAX_EARLY_SUBSCRIPTION_EVENTS_PER_TOKEN = 16; +const TERMINAL_TRANSACTION_WATCH_EVENTS = new Set([ + "finalized", + "error", + "invalid", + "dropped", +]); + function isJsonRpcObject( value: unknown, ): value is Record & { jsonrpc?: string } { @@ -226,7 +234,7 @@ export function requireBrokerLocalProvider( const BROKER_TAG = "[dot.li broker]"; function brokerLog(...args: unknown[]): void { - console.warn(BROKER_TAG, ...args); + log.debug(BROKER_TAG, ...args); } class ChainBroker { @@ -941,7 +949,13 @@ class ChainBroker { }); } - if (isJsonRpcObject(eventResult) && eventResult.event === "stop") { + if ( + isJsonRpcObject(eventResult) && + (eventResult.event === "stop" || + (message.method === "transactionWatch_v1_watchEvent" && + typeof eventResult.event === "string" && + TERMINAL_TRANSACTION_WATCH_EVENTS.has(eventResult.event))) + ) { brokerLog(`Token stopped by upstream: ${upstreamToken}`); for (const localToken of localTokens) { this.releaseOwnedToken(localToken, false); diff --git a/packages/protocol/src/chain-connection.ts b/packages/protocol/src/chain-connection.ts new file mode 100644 index 00000000..b9540519 --- /dev/null +++ b/packages/protocol/src/chain-connection.ts @@ -0,0 +1,351 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + JsonRpcMessage, + JsonRpcProvider, + JsonRpcRequest, +} from "@polkadot-api/json-rpc-provider"; +import { serializeError } from "@dotli/shared/errors"; +import { asChainConnectionError, ChainConnectionError } from "./errors"; + +export interface ChainConnection { + send(request: string): void; + responses(): AsyncIterable; + close(): void; +} + +export interface ChainConnectionTransport { + connect(genesisHash: string, connectionId: string): Promise; + send(connectionId: string, request: string): Promise; + disconnect(connectionId: string): Promise; +} + +interface ChainConnectionState { + readonly id: string; + readonly genesisHash: string; + status: "connecting" | "open" | "closed" | "failed"; + readonly messages: string[]; + wake: (() => void) | null; + terminalError: ChainConnectionError | null; + responsesStarted: boolean; +} + +export interface ChainConnectionClient { + connectChain(genesisHash: string): Promise; + handleMessage(connectionId: string, message: string): void; + handleHalt(connectionId: string, message?: string): void; + failAll(error: ChainConnectionError): void; +} + +interface ChainConnectionClientOptions { + readonly transport: ChainConnectionTransport; + readonly createConnectionId: () => string; + readonly onLateMessage?: (connectionId: string) => void; + readonly onDisconnectError?: (error: unknown) => void; + readonly onStateChange?: ( + state: "connecting" | "open" | "closed" | "failed", + connectionId: string, + genesisHash: string, + ) => void; +} + +export function createChainConnectionClient( + options: ChainConnectionClientOptions, +): ChainConnectionClient { + const states = new Map(); + + function wake(state: ChainConnectionState): void { + state.wake?.(); + state.wake = null; + } + + function failState( + state: ChainConnectionState, + error: ChainConnectionError, + ): void { + if (state.status === "closed" || state.status === "failed") { + return; + } + state.status = "failed"; + state.terminalError = error; + states.delete(state.id); + wake(state); + options.onStateChange?.("failed", state.id, state.genesisHash); + } + + function closeState(state: ChainConnectionState): void { + if (state.status === "closed" || state.status === "failed") { + return; + } + state.status = "closed"; + states.delete(state.id); + wake(state); + options.onStateChange?.("closed", state.id, state.genesisHash); + void options.transport.disconnect(state.id).catch((error: unknown) => { + options.onDisconnectError?.(error); + }); + } + + async function* responseIterator( + state: ChainConnectionState, + ): AsyncIterable { + if (state.responsesStarted) { + throw new Error("Chain responses can only be consumed once"); + } + state.responsesStarted = true; + try { + for (;;) { + const message = state.messages.shift(); + if (message !== undefined) { + yield message; + continue; + } + if (state.status === "failed") { + throw ( + state.terminalError ?? + new ChainConnectionError("CHAIN_HALTED", "Chain connection failed") + ); + } + if (state.status === "closed") { + return; + } + await new Promise((resolve) => { + state.wake = resolve; + }); + } + } finally { + closeState(state); + } + } + + async function connectChain(genesisHash: string): Promise { + const id = options.createConnectionId(); + const state: ChainConnectionState = { + id, + genesisHash, + status: "connecting", + messages: [], + wake: null, + terminalError: null, + responsesStarted: false, + }; + states.set(id, state); + options.onStateChange?.("connecting", id, genesisHash); + + try { + await options.transport.connect(genesisHash, id); + } catch (error: unknown) { + const connectionError = asChainConnectionError( + error, + "PROTOCOL_UNAVAILABLE", + ); + failState(state, connectionError); + throw connectionError; + } + + if (state.status === "failed") { + throw ( + state.terminalError ?? + new ChainConnectionError("CHAIN_HALTED", "Chain connection failed") + ); + } + if (state.status === "closed") { + throw new ChainConnectionError( + "CHAIN_HALTED", + "Chain connection closed during setup", + ); + } + state.status = "open"; + options.onStateChange?.("open", id, genesisHash); + + return { + send(request: string): void { + if (state.status !== "open") { + throw state.terminalError ?? new Error("Chain connection is closed"); + } + void options.transport.send(id, request).catch((error: unknown) => { + failState( + state, + asChainConnectionError(error, "PROTOCOL_UNAVAILABLE"), + ); + }); + }, + responses: () => responseIterator(state), + close: () => { + closeState(state); + }, + }; + } + + return { + connectChain, + handleMessage(connectionId, message) { + const state = states.get(connectionId); + if (state === undefined) { + options.onLateMessage?.(connectionId); + return; + } + state.messages.push(message); + wake(state); + }, + handleHalt(connectionId, message) { + const state = states.get(connectionId); + if (state === undefined) { + return; + } + failState( + state, + new ChainConnectionError( + "CHAIN_HALTED", + message ?? "Chain connection halted", + ), + ); + }, + failAll(error) { + for (const state of [...states.values()]) { + failState(state, error); + } + }, + }; +} + +function buildJsonRpcError( + request: JsonRpcRequest, + errorMessage: string, +): JsonRpcMessage | null { + if (request.id === undefined || request.id === null) { + return null; + } + return { + jsonrpc: "2.0", + id: request.id, + error: { code: -32603, message: errorMessage }, + }; +} + +function responseKey(message: JsonRpcMessage): string | null { + if (!("id" in message) || message.id === undefined || message.id === null) { + return null; + } + return `${typeof message.id}:${String(message.id)}`; +} + +function requestKey(message: JsonRpcRequest): string | null { + if (message.id === undefined || message.id === null) { + return null; + } + return `${typeof message.id}:${String(message.id)}`; +} + +export function createPapiChainProvider( + connect: () => Promise, + onError?: (message: string, error: unknown) => void, +): JsonRpcProvider { + return (onMessage) => { + let connection: ChainConnection | null = null; + let closed = false; + let terminalError: string | null = null; + const queued: JsonRpcRequest[] = []; + const outstanding = new Map(); + + function respondWithError(request: JsonRpcRequest, message: string): void { + const response = buildJsonRpcError(request, message); + if (response !== null) { + onMessage(response); + } + } + + function failOutstanding(error: unknown): void { + if (closed) { + return; + } + terminalError = serializeError(error); + onError?.("PAPI chain connection failed", error); + for (const request of outstanding.values()) { + respondWithError(request, terminalError); + } + outstanding.clear(); + queued.length = 0; + } + + function send(request: JsonRpcRequest): void { + if (closed) { + respondWithError(request, "Chain connection is closed"); + return; + } + if (terminalError !== null) { + respondWithError(request, terminalError); + return; + } + const key = requestKey(request); + if (key !== null) { + outstanding.set(key, request); + } + if (connection === null) { + queued.push(request); + return; + } + try { + connection.send(JSON.stringify(request)); + } catch (error: unknown) { + if (key !== null) { + outstanding.delete(key); + } + respondWithError(request, serializeError(error)); + } + } + + void connect() + .then((connected) => { + if (closed) { + connected.close(); + return; + } + connection = connected; + for (const request of queued.splice(0)) { + try { + connected.send(JSON.stringify(request)); + } catch (error: unknown) { + const key = requestKey(request); + if (key !== null) { + outstanding.delete(key); + } + respondWithError(request, serializeError(error)); + } + } + + void (async () => { + try { + for await (const response of connected.responses()) { + const parsed = JSON.parse(response) as JsonRpcMessage; + const key = responseKey(parsed); + if (key !== null) { + outstanding.delete(key); + } + onMessage(parsed); + } + failOutstanding(new Error("Chain connection closed")); + } catch (error: unknown) { + failOutstanding(error); + } + })(); + }) + .catch((error: unknown) => { + failOutstanding(error); + }); + + return { + send, + disconnect() { + if (closed) { + return; + } + closed = true; + queued.length = 0; + outstanding.clear(); + connection?.close(); + }, + }; + }; +} diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index de3baa14..a5533514 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -1,24 +1,19 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -import type { - JsonRpcConnection, - JsonRpcMessage, - JsonRpcProvider, - JsonRpcRequest, -} from "@polkadot-api/json-rpc-provider"; -import { ProtocolFatalError, ProtocolInitFailedError } from "./errors"; +import type { JsonRpcProvider } from "@polkadot-api/json-rpc-provider"; +import { + ChainConnectionError, + ProtocolFatalError, + ProtocolInitFailedError, +} from "./errors"; import type { ExecutableManifest, ManifestResult, RootManifest, } from "@dotli/resolver/manifest"; import { BASE_DOMAIN, type SiteId } from "@dotli/config/config"; -import { - getActiveGatewaySupportedGenesisHashes, - getActiveSupportedGenesisHashes, - getNetwork, -} from "@dotli/config/network"; +import { getNetwork } from "@dotli/config/network"; import { getBackend, type Backend } from "@dotli/config/mode"; import { log } from "@dotli/shared/log"; import { m } from "@dotli/metrics/metrics"; @@ -33,7 +28,13 @@ import { isSharedAuthRequestMethod, isSharedModeRequestMethod, } from "./auth-storage"; -import { serializeError } from "@dotli/shared/errors"; +import { + createChainConnectionClient, + createPapiChainProvider, + type ChainConnection, +} from "./chain-connection"; + +export type { ChainConnection } from "./chain-connection"; interface PendingRequest { resolve: (value: unknown) => void; @@ -41,12 +42,6 @@ interface PendingRequest { onProgress?: (message: string) => void; } -interface RemoteChainConnection { - onMessage: (message: JsonRpcMessage) => void; - pendingMessages: JsonRpcRequest[]; - connected: boolean; -} - export interface SharedAuthStorageChange { siteId: SiteId; key: string; @@ -61,10 +56,40 @@ let protocolIframe: HTMLIFrameElement | null = null; let hostFramePromise: Promise | null = null; let protocolReadyPromise: Promise | null = null; const pendingRequests = new Map(); -const chainConnections = new Map(); const sharedAuthListeners = new Set(); let listenerBound = false; let protocolReady = false; + +const chainConnectionClient = createChainConnectionClient({ + createConnectionId: createRequestId, + transport: { + async connect(genesisHash, connectionId) { + await postRequest("chainConnect", { genesisHash, connectionId }); + }, + async send(connectionId, request) { + await postRequest("chainSend", { connectionId, message: request }); + }, + async disconnect(connectionId) { + await postRequest("chainDisconnect", { connectionId }); + }, + }, + onLateMessage(connectionId) { + log.debug( + `[dot.li protocol] Ignoring late chain message for ${connectionId}`, + ); + }, + onDisconnectError(error) { + log.warn("[dot.li protocol] Remote disconnect failed:", error); + }, + onStateChange(state, connectionId, genesisHash) { + log.debug("[dot.li protocol] Chain connection state", { + state, + connectionId, + genesisHash, + backend: getBackend(), + }); + }, +}); interface ReadyWaiter { resolve: () => void; reject: (err: Error) => void; @@ -137,10 +162,7 @@ function resolveProtocolReady(): void { * was wrong and need a clean restart before chain operations run. * * Side effects callers should be aware of: - * - Any in-flight `postRequest()` whose response hasn't arrived will be - * orphaned: it will time out via the per-method timer instead of - * completing. Callers that have outstanding work should expect those - * rejections. + * - Any in-flight request and open chain connection is rejected immediately. * - Any `waitForProtocolReady()` waiter is rejected immediately rather * than waiting for `IFRAME_READY_TIMEOUT_MS`. * - In `shared-worker` mode, removing the iframe drops its @@ -154,6 +176,8 @@ export function resetProtocolFrame(): void { } function resetProtocolFrameState(reason?: Error): void { + const resetError = + reason ?? new Error("Protocol frame state reset before request completed"); protocolIframe?.remove(); protocolIframe = null; hostFramePromise = null; @@ -164,12 +188,17 @@ function resetProtocolFrameState(reason?: Error): void { const orphaned = pendingReadyResolvers; pendingReadyResolvers = []; if (orphaned.length > 0) { - const err = - reason ?? new Error("Protocol frame state reset before ready signal"); for (const waiter of orphaned) { - waiter.reject(err); + waiter.reject(resetError); } } + for (const [id, pending] of pendingRequests) { + pendingRequests.delete(id); + pending.reject(resetError); + } + chainConnectionClient.failAll( + new ChainConnectionError("PROTOCOL_UNAVAILABLE", resetError.message), + ); } function bindMessageListener(): void { @@ -210,8 +239,16 @@ function bindMessageListener(): void { if (msg.ok) { pending.resolve(msg.result); } else { - const err = new Error(msg.error || "Unknown protocol error"); - err.name = "ProtocolResponseError"; + const err = + msg.code === undefined + ? new Error(msg.error || "Unknown protocol error") + : new ChainConnectionError( + msg.code, + msg.error || "Unknown protocol error", + ); + if (msg.code === undefined) { + err.name = "ProtocolResponseError"; + } pending.reject(err); } return; @@ -237,6 +274,10 @@ function bindMessageListener(): void { pending.reject(err); } + chainConnectionClient.failAll( + new ChainConnectionError("PROTOCOL_UNAVAILABLE", err.message), + ); + // Route through the same reset path used by iframe load failures // so callers blocked on `waitForProtocolReady()` // (`pendingReadyResolvers`) are rejected immediately rather than @@ -250,37 +291,11 @@ function bindMessageListener(): void { return; } case "chain-message": { - const conn = chainConnections.get(msg.connectionId); - if (!conn) { - log.warn( - `[dot.li protocol] chain-message for unknown connectionId: ${msg.connectionId} (known: ${[...chainConnections.keys()].join(", ")})`, - ); - return; - } - // Envelope ships `message` as a string. The provider contract - // wants the consumer to receive a parsed `JsonRpcMessage`. - let parsed: JsonRpcMessage; - try { - parsed = JSON.parse(msg.message) as JsonRpcMessage; - } catch (err: unknown) { - log.error( - `[dot.li protocol] chain-message JSON parse failed (conn=${msg.connectionId.slice(-8)}):`, - err instanceof Error ? err.message : err, - ); - return; - } - try { - conn.onMessage(parsed); - } catch (err: unknown) { - log.error( - `[dot.li protocol] onMessage threw (conn=${msg.connectionId.slice(-8)}):`, - err instanceof Error ? err.message : err, - ); - } + chainConnectionClient.handleMessage(msg.connectionId, msg.message); return; } case "chain-halt": - chainConnections.delete(msg.connectionId); + chainConnectionClient.handleHalt(msg.connectionId); return; case "request": // Ignore inbound requests on the client side @@ -548,6 +563,7 @@ async function postRequest( if (timer !== null) { clearTimeout(timer); } + stopReq(); reject(reason instanceof Error ? reason : new Error(String(reason))); }, onProgress, @@ -685,129 +701,17 @@ export function subscribeSharedAuthStorage( }; } -export function isRemoteChainSupported(genesisHash: string): boolean { - // Advertise only what the *active* backend can actually serve. Gateway mode - // bridges a curated RPC subset, while smoldot can run any configured chain. - const supported = - getBackend() === "rpc-gateway" - ? getActiveGatewaySupportedGenesisHashes() - : getActiveSupportedGenesisHashes(); - return supported.has(genesisHash.toLowerCase()); -} - -/** - * Notification-style requests (no `id`) get `null`, nothing to respond to. - */ -function buildJsonRpcError( - request: JsonRpcRequest, - errorMessage: string, -): JsonRpcMessage | null { - if (request.id === undefined || request.id === null) { - return null; - } - return { - jsonrpc: "2.0", - id: request.id, - error: { code: -32603, message: errorMessage }, - }; +export function connectChain(genesisHash: string): Promise { + return chainConnectionClient.connectChain(genesisHash); } export function createRemoteChainProvider( genesisHash: string, -): JsonRpcProvider | null { - if (!isRemoteChainSupported(genesisHash)) { - return null; - } - - return (onMessage): JsonRpcConnection => { - const connectionId = createRequestId(); - const remote: RemoteChainConnection = { - onMessage, - pendingMessages: [], - connected: false, - }; - - chainConnections.set(connectionId, remote); - - void ensureProtocolFrame() - .then(async () => { - await postRequest("chainConnect", { genesisHash, connectionId }); - remote.connected = true; - for (const message of remote.pendingMessages) { - void postRequest("chainSend", { - connectionId, - message: JSON.stringify(message), - }).catch((error: unknown) => { - const errResponse = buildJsonRpcError( - message, - serializeError(error), - ); - if (errResponse !== null) { - onMessage(errResponse); - } - }); - } - remote.pendingMessages = []; - }) - .catch((error: unknown) => { - // Connection failed. Send JSON-RPC error responses for all - // pending messages so polkadot-api's client knows the connection - // died instead of hanging on "Not connected" forever. - const reason = serializeError(error); - log.error("[dot.li protocol] Failed to connect remote chain:", error); - for (const pending of remote.pendingMessages) { - const errResponse = buildJsonRpcError(pending, reason); - if (errResponse !== null) { - onMessage(errResponse); - } - } - remote.pendingMessages = []; - chainConnections.delete(connectionId); - }); - - return { - send(message) { - const current = chainConnections.get(connectionId); - if (!current) { - // Connection was removed (failed or disconnected). - // Respond with an error so the caller doesn't hang. - const errResponse = buildJsonRpcError( - message, - "Chain connection is closed", - ); - if (errResponse !== null) { - onMessage(errResponse); - } - return; - } - if (!current.connected) { - current.pendingMessages.push(message); - return; - } - void postRequest("chainSend", { - connectionId, - message: JSON.stringify(message), - }).catch((error: unknown) => { - const reason = serializeError(error); - log.error("[dot.li protocol] Remote chain send failed:", error); - const errResponse = buildJsonRpcError(message, reason); - if (errResponse !== null) { - onMessage(errResponse); - } - }); - }, - disconnect() { - const current = chainConnections.get(connectionId); - chainConnections.delete(connectionId); - if (!current) { - return; - } - void postRequest("chainDisconnect", { connectionId }).catch( - (error: unknown) => { - log.warn("[dot.li protocol] Remote disconnect failed:", error); - }, - ); - }, - }; - }; +): JsonRpcProvider { + return createPapiChainProvider( + () => connectChain(genesisHash), + (message, error) => { + log.error(`[dot.li protocol] ${message}:`, error); + }, + ); } diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 76911d84..1c1dc1c1 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,6 +1,8 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only +import { serializeError } from "@dotli/shared/errors"; + export class ProtocolFatalError extends Error { constructor(message: string) { super(message); @@ -14,3 +16,39 @@ export class ProtocolInitFailedError extends Error { this.name = "ProtocolInitFailedError"; } } + +export type ChainConnectionErrorCode = + | "UNSUPPORTED_CHAIN" + | "PROTOCOL_UNAVAILABLE" + | "UPSTREAM_CONNECTION_FAILED" + | "CHAIN_HALTED"; + +export class ChainConnectionError extends Error { + readonly code: ChainConnectionErrorCode; + + constructor(code: ChainConnectionErrorCode, message: string) { + super(message); + this.name = "ChainConnectionError"; + this.code = code; + } +} + +export function asChainConnectionError( + error: unknown, + fallbackCode: ChainConnectionErrorCode, +): ChainConnectionError { + if (error instanceof ChainConnectionError) { + return error; + } + return new ChainConnectionError(fallbackCode, serializeError(error)); +} + +export function toProtocolErrorPayload(error: unknown): { + error: string; + code?: ChainConnectionErrorCode; +} { + if (error instanceof ChainConnectionError) { + return { error: error.message, code: error.code }; + } + return { error: serializeError(error) }; +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b693efdc..02e3db5f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,6 +1,8 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only +import type { ChainConnectionErrorCode } from "./errors"; + export interface ProtocolRequestMap { warmup: Record; resolveDotName: { label: string }; @@ -54,6 +56,7 @@ export interface ProtocolErrorEnvelope { id: string; ok: false; error: string; + code?: ChainConnectionErrorCode; } export interface ProtocolChainMessageEnvelope { diff --git a/packages/protocol/tests/broker.test.ts b/packages/protocol/tests/broker.test.ts index 788c375f..1e6f8db8 100644 --- a/packages/protocol/tests/broker.test.ts +++ b/packages/protocol/tests/broker.test.ts @@ -775,6 +775,108 @@ describe("createChainBrokerManager", () => { }); }); + it("routes transaction watch events and releases terminal subscriptions", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messages: string[] = []; + const connection = manager.connectRemote("bulletin", "conn-a", (message) => + messages.push(message), + ); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x1234"], + }), + ); + + const upstreamRequest = harness.sent[0] as { id: string }; + harness.emit({ + jsonrpc: "2.0", + id: upstreamRequest.id, + result: "upstream-watch", + }); + + const localToken = (JSON.parse(messages[0] ?? "{}") as { result: string }) + .result; + expect(localToken).not.toBe("upstream-watch"); + + harness.emit({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: "upstream-watch", + result: { event: "validated" }, + }, + }); + harness.emit({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: "upstream-watch", + result: { + event: "finalized", + block: { hash: "0xabc", index: 0 }, + }, + }, + }); + + expect(messages.slice(1).map((message) => JSON.parse(message))).toEqual([ + { + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: localToken, + result: { event: "validated" }, + }, + }, + { + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: localToken, + result: { + event: "finalized", + block: { hash: "0xabc", index: 0 }, + }, + }, + }, + ]); + + connection?.disconnect(); + expect(harness.sent).toHaveLength(1); + }); + + it("unwatches an active transaction when its connection closes", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const connection = manager.connectRemote("bulletin", "conn-a", () => {}); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x1234"], + }), + ); + const upstreamRequest = harness.sent[0] as { id: string }; + harness.emit({ + jsonrpc: "2.0", + id: upstreamRequest.id, + result: "upstream-watch", + }); + + connection?.disconnect(); + + expect(harness.sent[1]).toMatchObject({ + method: "transactionWatch_v1_unwatch", + params: ["upstream-watch"], + }); + }); + it("forwards exactly one upstream unpin when two sessions unpin the same shared block", () => { const harness = createProviderHarness(); const manager = createChainBrokerManager(() => harness.provider); diff --git a/packages/protocol/tests/chain-connection.test.ts b/packages/protocol/tests/chain-connection.test.ts new file mode 100644 index 00000000..769a4d7a --- /dev/null +++ b/packages/protocol/tests/chain-connection.test.ts @@ -0,0 +1,351 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, expect, it, vi } from "vitest"; +import { + createChainConnectionClient, + createPapiChainProvider, + type ChainConnectionTransport, +} from "@dotli/protocol/chain-connection"; +import { ChainConnectionError } from "@dotli/protocol/errors"; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createTransport(): { + transport: ChainConnectionTransport; + connect: ReturnType; + send: ReturnType; + disconnect: ReturnType; +} { + const connect = vi.fn(async () => undefined); + const send = vi.fn(async () => undefined); + const disconnect = vi.fn(async () => undefined); + return { + connect, + send, + disconnect, + transport: { connect, send, disconnect }, + }; +} + +describe("chain connection client", () => { + it("resolves only after the protocol acknowledges the connection", async () => { + const acknowledgement = deferred(); + const fixture = createTransport(); + fixture.connect.mockReturnValue(acknowledgement.promise); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + let resolved = false; + + const pending = client.connectChain("0x1234").then((connection) => { + resolved = true; + return connection; + }); + await Promise.resolve(); + + expect(resolved).toBe(false); + acknowledgement.resolve(undefined); + await pending; + expect(resolved).toBe(true); + }); + + it("routes string responses to their logical connection", async () => { + const fixture = createTransport(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const connection = await client.connectChain("0x1234"); + const responses = connection.responses()[Symbol.asyncIterator](); + + client.handleMessage("connection-1", '{"jsonrpc":"2.0","id":1}'); + + await expect(responses.next()).resolves.toEqual({ + done: false, + value: '{"jsonrpc":"2.0","id":1}', + }); + await responses.return?.(); + }); + + it("assigns a unique ID to each logical connection", async () => { + const fixture = createTransport(); + const ids = ["connection-1", "connection-2"]; + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => ids.shift() ?? "unexpected", + }); + + const first = await client.connectChain("0x1234"); + const second = await client.connectChain("0x1234"); + + expect(fixture.connect.mock.calls).toEqual([ + ["0x1234", "connection-1"], + ["0x1234", "connection-2"], + ]); + first.close(); + second.close(); + }); + + it("terminates a waiting response iterator when the chain halts", async () => { + const fixture = createTransport(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const connection = await client.connectChain("0x1234"); + const responses = connection.responses()[Symbol.asyncIterator](); + const next = responses.next(); + + client.handleHalt("connection-1", "smoldot stopped"); + + await expect(next).rejects.toMatchObject({ + code: "CHAIN_HALTED", + message: "smoldot stopped", + }); + }); + + it("removes failed setup state and preserves the typed failure", async () => { + const fixture = createTransport(); + fixture.connect.mockRejectedValue( + new ChainConnectionError("UNSUPPORTED_CHAIN", "Unsupported chain"), + ); + const lateMessage = vi.fn(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + onLateMessage: lateMessage, + }); + + await expect(client.connectChain("0x1234")).rejects.toMatchObject({ + code: "UNSUPPORTED_CHAIN", + }); + client.handleMessage("connection-1", "late"); + expect(lateMessage).toHaveBeenCalledWith("connection-1"); + }); + + it("disconnects exactly once", async () => { + const fixture = createTransport(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const connection = await client.connectChain("0x1234"); + + connection.close(); + connection.close(); + + await vi.waitFor(() => { + expect(fixture.disconnect).toHaveBeenCalledTimes(1); + }); + }); + + it("rejects sends after close", async () => { + const fixture = createTransport(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const connection = await client.connectChain("0x1234"); + + connection.close(); + + expect(() => connection.send("request")).toThrow( + "Chain connection is closed", + ); + expect(fixture.send).not.toHaveBeenCalled(); + }); + + it("terminates every response iterator after a protocol failure", async () => { + const fixture = createTransport(); + let nextId = 0; + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => `connection-${String(++nextId)}`, + }); + const first = await client.connectChain("0x1234"); + const second = await client.connectChain("0x5678"); + const firstNext = first.responses()[Symbol.asyncIterator]().next(); + const secondNext = second.responses()[Symbol.asyncIterator]().next(); + + client.failAll( + new ChainConnectionError("PROTOCOL_UNAVAILABLE", "Protocol frame failed"), + ); + + await expect(firstNext).rejects.toMatchObject({ + code: "PROTOCOL_UNAVAILABLE", + }); + await expect(secondNext).rejects.toMatchObject({ + code: "PROTOCOL_UNAVAILABLE", + }); + }); +}); + +describe("PAPI chain provider adapter", () => { + it("buffers requests until connectChain resolves and preserves order", async () => { + const acknowledgement = deferred(); + const fixture = createTransport(); + fixture.connect.mockReturnValue(acknowledgement.promise); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const provider = createPapiChainProvider(() => + client.connectChain("0x1234"), + ); + const connection = provider(vi.fn()); + const first = { + jsonrpc: "2.0" as const, + id: 1, + method: "first", + params: [], + }; + const second = { + jsonrpc: "2.0" as const, + id: 2, + method: "second", + params: [], + }; + + connection.send(first); + connection.send(second); + expect(fixture.send).not.toHaveBeenCalled(); + acknowledgement.resolve(undefined); + + await vi.waitFor(() => { + expect(fixture.send.mock.calls).toEqual([ + ["connection-1", JSON.stringify(first)], + ["connection-1", JSON.stringify(second)], + ]); + }); + connection.disconnect(); + }); + + it("converts string responses back to PAPI messages", async () => { + const fixture = createTransport(); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const onMessage = vi.fn(); + const provider = createPapiChainProvider(() => + client.connectChain("0x1234"), + ); + const connection = provider(onMessage); + + await vi.waitFor(() => { + expect(fixture.connect).toHaveBeenCalled(); + }); + client.handleMessage( + "connection-1", + '{"jsonrpc":"2.0","id":1,"result":"ok"}', + ); + + await vi.waitFor(() => { + expect(onMessage).toHaveBeenCalledWith({ + jsonrpc: "2.0", + id: 1, + result: "ok", + }); + }); + connection.disconnect(); + }); + + it("returns JSON-RPC errors for queued requests when setup fails", async () => { + const connectError = new ChainConnectionError( + "UNSUPPORTED_CHAIN", + "Unsupported chain", + ); + const onMessage = vi.fn(); + const provider = createPapiChainProvider(async () => { + throw connectError; + }); + const connection = provider(onMessage); + + connection.send({ + jsonrpc: "2.0", + id: "request-1", + method: "test", + params: [], + }); + + await vi.waitFor(() => { + expect(onMessage).toHaveBeenCalledWith({ + jsonrpc: "2.0", + id: "request-1", + error: { code: -32603, message: "Unsupported chain" }, + }); + }); + + connection.send({ + jsonrpc: "2.0", + id: "request-2", + method: "test-again", + params: [], + }); + expect(onMessage).toHaveBeenLastCalledWith({ + jsonrpc: "2.0", + id: "request-2", + error: { code: -32603, message: "Unsupported chain" }, + }); + }); + + it("does not fabricate an error response for a failed notification", async () => { + const onMessage = vi.fn(); + const provider = createPapiChainProvider(async () => { + throw new Error("Connection failed"); + }); + const connection = provider(onMessage); + + connection.send({ + jsonrpc: "2.0", + method: "notify", + params: [], + }); + await Promise.resolve(); + await Promise.resolve(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("closes an eventual connection after disconnect during setup", async () => { + const acknowledgement = deferred(); + const fixture = createTransport(); + fixture.connect.mockReturnValue(acknowledgement.promise); + const client = createChainConnectionClient({ + transport: fixture.transport, + createConnectionId: () => "connection-1", + }); + const provider = createPapiChainProvider(() => + client.connectChain("0x1234"), + ); + const connection = provider(vi.fn()); + + connection.send({ + jsonrpc: "2.0", + id: 1, + method: "queued", + params: [], + }); + connection.disconnect(); + connection.disconnect(); + acknowledgement.resolve(undefined); + + await vi.waitFor(() => { + expect(fixture.disconnect).toHaveBeenCalledTimes(1); + }); + expect(fixture.send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/protocol/tests/messages.test.ts b/packages/protocol/tests/messages.test.ts index 4e80f57f..a2e4bed3 100644 --- a/packages/protocol/tests/messages.test.ts +++ b/packages/protocol/tests/messages.test.ts @@ -49,6 +49,7 @@ describe("isProtocolEnvelope", () => { id: "test-1", ok: false, error: "something failed", + code: "UPSTREAM_CONNECTION_FAILED", }; expect(isProtocolEnvelope(envelope)).toBe(true); }); diff --git a/packages/resolver/src/chains.ts b/packages/resolver/src/chains.ts index 4578a94b..d1c44b7e 100644 --- a/packages/resolver/src/chains.ts +++ b/packages/resolver/src/chains.ts @@ -38,33 +38,33 @@ export function isChainSupported(genesisHash: string): boolean { * This means no duplicate parachain sync: the resolver's Asset Hub is * already synced by the time a dApp loads, so chain queries work immediately. */ -export function createChainProvider( +export function createSmoldotUpstreamProvider( genesisHash: string, ): JsonRpcProvider | null { const key = genesisHash.toLowerCase(); const cfg = getActiveServicesConfig(); if (key === cfg.assethub.genesis.toLowerCase()) { - log.warn("[dot.li chains] Returning shared Asset Hub provider"); + log.debug("[dot.li chains] Returning shared Asset Hub provider"); return getDappAssetHubProvider(); } if (key === cfg.relay.genesis.toLowerCase()) { - log.warn("[dot.li chains] Returning shared relay chain provider"); + log.debug("[dot.li chains] Returning shared relay chain provider"); return getSmProvider(() => getRelayChain().then((chain) => makeNonRemovingChain(chain)), ); } if (key === cfg.bulletin.genesis.toLowerCase()) { - log.warn("[dot.li chains] Returning Bulletin Paseo provider (smoldot)"); + log.debug("[dot.li chains] Returning Bulletin Paseo provider (smoldot)"); return getSmProvider(() => getBulletinChain().then((chain) => makeNonRemovingChain(chain)), ); } if (key === cfg.people.genesis.toLowerCase()) { - log.warn("[dot.li chains] Returning People Paseo provider (smoldot)"); + log.debug("[dot.li chains] Returning People Paseo provider (smoldot)"); return getSmProvider(() => getPeopleChain().then((chain) => makeNonRemovingChain(chain)), ); diff --git a/packages/resolver/src/rpc-chain.ts b/packages/resolver/src/rpc-chain.ts index 230438b1..65b0fae1 100644 --- a/packages/resolver/src/rpc-chain.ts +++ b/packages/resolver/src/rpc-chain.ts @@ -21,27 +21,10 @@ */ import { getWsProvider } from "polkadot-api/ws"; import type { JsonRpcProvider } from "polkadot-api"; -import { - getActiveCoreGatewayChains, - getActiveGatewayChains, -} from "@dotli/config/network"; +import { getActiveCoreGatewayChains } from "@dotli/config/network"; import type { ChainService } from "@dotli/config/network"; -/** - * Resolve a genesis hash to its active-network chain, or `null` when gateway - * mode cannot reach it. Backed by `getActiveGatewayChains()` so the set of - * gateway-served chains stays identical to what the host advertises via - * `isRemoteChainSupported`. - */ -function gatewayChain(genesisHash: string): ChainService | null { - const key = genesisHash.toLowerCase(); - return ( - getActiveGatewayChains().find((c) => c.genesis.toLowerCase() === key) ?? - null - ); -} - -function coreGatewayChain(genesisHash: string): ChainService | null { +function upstreamGatewayChain(genesisHash: string): ChainService | null { const key = genesisHash.toLowerCase(); return ( getActiveCoreGatewayChains().find( @@ -50,28 +33,16 @@ function coreGatewayChain(genesisHash: string): ChainService | null { ); } -/** Whether gateway mode can serve chain calls for `genesisHash`. */ -export function isRpcChainSupported(genesisHash: string): boolean { - return gatewayChain(genesisHash) !== null; -} - -/** A WSS JSON-RPC provider for `genesisHash`, or `null` when gateway mode does not support that chain. */ -export function createRpcChainProvider( - genesisHash: string, -): JsonRpcProvider | null { - return createGatewayProvider(gatewayChain(genesisHash)); -} - -/** Whether the host-owned Rust core can reach `genesisHash` in gateway mode. */ -export function isCoreRpcChainSupported(genesisHash: string): boolean { - return coreGatewayChain(genesisHash) !== null; +/** Whether the protocol runtime can connect to `genesisHash` in gateway mode. */ +export function isRpcUpstreamSupported(genesisHash: string): boolean { + return upstreamGatewayChain(genesisHash) !== null; } -/** Gateway provider for host-owned Rust-core traffic, including Bulletin. */ -export function createCoreRpcChainProvider( +/** A WSS upstream provider for an operational gateway chain. */ +export function createRpcUpstreamProvider( genesisHash: string, ): JsonRpcProvider | null { - return createGatewayProvider(coreGatewayChain(genesisHash)); + return createGatewayProvider(upstreamGatewayChain(genesisHash)); } function createGatewayProvider( diff --git a/packages/resolver/src/smoldot.ts b/packages/resolver/src/smoldot.ts index 2d61800c..90a6b8d4 100644 --- a/packages/resolver/src/smoldot.ts +++ b/packages/resolver/src/smoldot.ts @@ -255,9 +255,11 @@ export function getSmoldotDirect(): SmoldotClient { if (smoldotInstance !== null) { return smoldotInstance; } - log.warn("[dot.li smoldot] Creating smoldot via start() (current thread)"); + log.debug("[dot.li smoldot] Creating smoldot via start() (current thread)"); smoldotInstance = startSmoldotDirect({ - maxLogLevel: 5, + // Keep errors, warnings, and lifecycle info. Per-request debug/trace logs + // overwhelm the browser console and are not used for health detection. + maxLogLevel: 3, logCallback: smoldotLogCallback, // Smoldot's own auto-detection (no-auto-bytecode-browser.js) is buggy // and never sets this in browsers, so peer-gossipped `ws://[ip]` addrs @@ -273,7 +275,7 @@ export function getSmoldotDirect(): SmoldotClient { // https://github.com/paritytech/smoldot/pull/3303 forbidWebRtc: true, }); - log.warn("[dot.li smoldot] Smoldot client ready (direct mode)"); + log.debug("[dot.li smoldot] Smoldot client ready (direct mode)"); return smoldotInstance; } @@ -281,9 +283,9 @@ export function getSmoldot(): SmoldotClient { if (smoldotInstance !== null) { return smoldotInstance; } - log.warn("[dot.li smoldot] Creating smoldot via startFromWorker()"); + log.debug("[dot.li smoldot] Creating smoldot via startFromWorker()"); smoldotInstance = startFromWorker(new SmWorker(), { - maxLogLevel: 5, + maxLogLevel: 3, logCallback: smoldotLogCallback, forbidNonLocalWs: true, }); @@ -543,7 +545,7 @@ export function getDappAssetHubChain(): Promise { /** * Return a provider backed by the dApp's fresh Asset Hub chain. - * Used by `createChainProvider()` for remote dApp connections. + * Used by `createSmoldotUpstreamProvider()` for remote chain connections. */ export function getDappAssetHubProvider(): JsonRpcProvider { return getSmProvider(() => getDappAssetHubChain()); diff --git a/packages/resolver/tests/rpc-chain.test.ts b/packages/resolver/tests/rpc-chain.test.ts index e61b35df..2fc0afff 100644 --- a/packages/resolver/tests/rpc-chain.test.ts +++ b/packages/resolver/tests/rpc-chain.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { getActiveServicesConfig } from "@dotli/config/network"; import { - createCoreRpcChainProvider, - createRpcChainProvider, - isCoreRpcChainSupported, - isRpcChainSupported, + createRpcUpstreamProvider, + isRpcUpstreamSupported, } from "@dotli/resolver/rpc-chain"; const mocks = vi.hoisted(() => ({ @@ -19,38 +17,34 @@ describe("rpc-chain", () => { it("supports the active People chain when RPC endpoints are configured", () => { const people = getActiveServicesConfig().people; - expect(isRpcChainSupported(people.genesis)).toBe(true); + expect(isRpcUpstreamSupported(people.genesis)).toBe(true); const provider = {}; mocks.getWsProvider.mockReturnValueOnce(provider); - expect(createRpcChainProvider(people.genesis)).toBe(provider); + expect(createRpcUpstreamProvider(people.genesis)).toBe(provider); expect(mocks.getWsProvider).toHaveBeenCalledWith([...people.rpcs], { heartbeatTimeout: 120_000, }); }); it("rejects unknown genesis hashes", () => { - expect(isRpcChainSupported("0xdeadbeef")).toBe(false); - expect(createRpcChainProvider("0xdeadbeef")).toBeNull(); + expect(isRpcUpstreamSupported("0xdeadbeef")).toBe(false); + expect(createRpcUpstreamProvider("0xdeadbeef")).toBeNull(); }); - it("As a dotli integrator, the host reserves Bulletin RPC access for the host-owned Rust core", () => { + it("keeps Bulletin operational in the protocol runtime", () => { // Given const bulletin = getActiveServicesConfig().bulletin; const provider = {}; mocks.getWsProvider.mockReturnValueOnce(provider); // When - const productSupported = isRpcChainSupported(bulletin.genesis); - const productProvider = createRpcChainProvider(bulletin.genesis); - const coreSupported = isCoreRpcChainSupported(bulletin.genesis); - const coreProvider = createCoreRpcChainProvider(bulletin.genesis); + const upstreamSupported = isRpcUpstreamSupported(bulletin.genesis); + const upstreamProvider = createRpcUpstreamProvider(bulletin.genesis); // Then - expect(productSupported).toBe(false); - expect(productProvider).toBeNull(); - expect(coreSupported).toBe(true); - expect(coreProvider).toBe(provider); + expect(upstreamSupported).toBe(true); + expect(upstreamProvider).toBe(provider); }); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 94728b76..f5072410 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,7 +5,7 @@ "version": "0.6.0", "type": "module", "exports": { - "./*": "./src/*" + "./*": "./src/*.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js index 46d277e7..bb4d0373 100644 --- a/packages/ui/eslint.config.js +++ b/packages/ui/eslint.config.js @@ -11,5 +11,29 @@ export default [ tsconfigRootDir: import.meta.dirname, }, }, + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@dotli/protocol/broker", + message: + "Host code must open chains through @dotli/protocol/client.", + }, + { + name: "@dotli/resolver/chains", + message: + "Smoldot upstream ownership belongs to the protocol runtime.", + }, + { + name: "@dotli/resolver/rpc-chain", + message: + "RPC upstream ownership belongs to the protocol runtime.", + }, + ], + }, + ], + }, }, ]; diff --git a/packages/ui/src/bulletin-bitswap.ts b/packages/ui/src/bulletin-bitswap.ts index 143d8e0b..863211b5 100644 --- a/packages/ui/src/bulletin-bitswap.ts +++ b/packages/ui/src/bulletin-bitswap.ts @@ -2,15 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isResponse } from "@polkadot-api/json-rpc-provider"; -import type { - JsonRpcConnection, - JsonRpcMessage, -} from "@polkadot-api/json-rpc-provider"; +import type { JsonRpcMessage } from "@polkadot-api/json-rpc-provider"; import { hexToBytes } from "@noble/hashes/utils.js"; -import { - createRemoteChainProvider, - isRemoteChainSupported, -} from "@dotli/protocol/client"; +import { connectChain, type ChainConnection } from "@dotli/protocol/client"; import { isSandboxOrigin } from "@dotli/config/config"; import { getBackend } from "@dotli/config/mode"; import { getActiveServicesConfig } from "@dotli/config/network"; @@ -37,55 +31,72 @@ interface PendingResolver { let nextId = 1; const pending = new Map(); -let connection: JsonRpcConnection | null = null; +let connectionPromise: Promise | null = null; -function ensureConnection(): JsonRpcConnection { - if (connection !== null) { - return connection; +function rejectPending(error: Error): void { + for (const entry of pending.values()) { + entry.reject(error); } - const bulletinGenesis = getActiveServicesConfig().bulletin.genesis; - const provider = createRemoteChainProvider(bulletinGenesis); - if (provider === null) { - throw new Error( - `Bulletin Paseo (${bulletinGenesis}) is not in the supported chain set`, - ); + pending.clear(); +} + +function ensureConnection(): Promise { + if (connectionPromise !== null) { + return connectionPromise; } - connection = provider((message: JsonRpcMessage) => { - if (!isResponse(message)) { - return; - } - if (typeof message.id !== "number") { - return; - } - const entry = pending.get(message.id); - if (entry === undefined) { - return; - } - pending.delete(message.id); - if ("error" in message) { - const err = new Error( - `bitswap_v1_get failed (code=${String(message.error.code)}): ${message.error.message}`, - ); - (err as { code?: number }).code = message.error.code; - entry.reject(err); - return; - } - if (typeof message.result !== "string") { - entry.reject( - new Error( - `bitswap_v1_get: expected hex string result, got ${typeof message.result}`, - ), - ); - return; - } - // Parse hex to bytes ONCE host-side. The sandbox-bound buffer is then - // transferred zero-copy via postMessage instead of cloning an 8 MB - // hex string and re-parsing on the other side. - const hex = message.result; - const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; - entry.resolve(hexToBytes(stripped)); - }); - return connection; + + const bulletinGenesis = getActiveServicesConfig().bulletin.genesis; + const opening = connectChain(bulletinGenesis); + connectionPromise = opening; + + void opening + .then(async (connection) => { + for await (const encoded of connection.responses()) { + const message = JSON.parse(encoded) as JsonRpcMessage; + if (!isResponse(message) || typeof message.id !== "number") { + continue; + } + const entry = pending.get(message.id); + if (entry === undefined) { + continue; + } + pending.delete(message.id); + if ("error" in message) { + const err = new Error( + `bitswap_v1_get failed (code=${String(message.error.code)}): ${message.error.message}`, + ); + (err as { code?: number }).code = message.error.code; + entry.reject(err); + continue; + } + if (typeof message.result !== "string") { + entry.reject( + new Error( + `bitswap_v1_get: expected hex string result, got ${typeof message.result}`, + ), + ); + continue; + } + // Parse hex to bytes ONCE host-side. The sandbox-bound buffer is then + // transferred zero-copy via postMessage instead of cloning an 8 MB + // hex string and re-parsing on the other side. + const hex = message.result; + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + entry.resolve(hexToBytes(stripped)); + } + throw new Error("Bulletin chain connection closed"); + }) + .catch((error: unknown) => { + if (connectionPromise === opening) { + connectionPromise = null; + } + const connectionError = + error instanceof Error ? error : new Error(serializeError(error)); + rejectPending(connectionError); + log.error("[dot.li bitswap] Bulletin connection failed:", error); + }); + + return opening; } function errorCode(err: unknown): number | null { @@ -136,7 +147,7 @@ export async function bitswapGet(cid: string): Promise { function sendOnce(cid: string, timeoutMs: number): Promise { const id = nextId++; - const conn = ensureConnection(); + const connection = ensureConnection(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); @@ -156,12 +167,30 @@ function sendOnce(cid: string, timeoutMs: number): Promise { reject(err); }, }); - conn.send({ - jsonrpc: "2.0", - id, - method: "bitswap_v1_get", - params: [cid], - }); + void connection + .then((conn) => { + if (!pending.has(id)) { + return; + } + conn.send( + JSON.stringify({ + jsonrpc: "2.0", + id, + method: "bitswap_v1_get", + params: [cid], + }), + ); + }) + .catch((error: unknown) => { + const entry = pending.get(id); + if (entry === undefined) { + return; + } + pending.delete(id); + entry.reject( + error instanceof Error ? error : new Error(serializeError(error)), + ); + }); }); } @@ -201,16 +230,13 @@ function isBitswapGetMessage(value: unknown): value is BitswapGetMessage { /** Idempotent. Call once at host startup. */ export function listenForSandboxBitswap(): void { + // Chain support is now decided by the protocol runtime and surfaces as + // `UNSUPPORTED_CHAIN` at connect time, so only the gateway case is + // predictable at startup. if (getBackend() === "rpc-gateway") { log.warn( "[dot.li bitswap-relay] Bitswap is unavailable in RPC gateway mode; sandbox bitswap requests will fail.", ); - } else if ( - !isRemoteChainSupported(getActiveServicesConfig().bulletin.genesis) - ) { - log.warn( - "[dot.li bitswap-relay] Bulletin not in supported chain set; sandbox bitswap requests will fail.", - ); } window.addEventListener("message", (event: MessageEvent) => { const data: unknown = event.data; diff --git a/packages/ui/src/host-callbacks/Chain.ts b/packages/ui/src/host-callbacks/Chain.ts index 0f565daa..f18b6d21 100644 --- a/packages/ui/src/host-callbacks/Chain.ts +++ b/packages/ui/src/host-callbacks/Chain.ts @@ -1,140 +1,14 @@ -// dot.li — TrUAPI chain callback -// -// Routes product chain RPC traffic through whichever backend the user -// has selected in the host shell ("Light Client" via smoldot, or -// "RPC Node" via curated WSS endpoints). -// -// Without this callback, truapi-server would fall back to its own -// bundled smoldot — which would ignore the toggle, double the -// light-client footprint, and rebuild a fresh chain alongside the one -// dotli's resolver already maintains. Routing through dotli's existing -// providers reuses already-synced chains and respects the toggle. +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Web-host implementation of Rust Core's chain-provider callback. Physical +// chain ownership stays in the protocol iframe or SharedWorker; this boundary +// only converts the genesis hash to the protocol client's string-wire API. import { bytesToHex } from "@parity/truapi/scale"; -import type { - JsonRpcRequest, - JsonRpcProvider, -} from "@polkadot-api/json-rpc-provider"; import type { ChainProvider } from "@parity/truapi-host"; -import type { PlatformJsonRpcConnection } from "@parity/truapi-host"; -import { getBackend } from "@dotli/config/mode"; -import { createChainBrokerManager } from "@dotli/protocol/broker"; -import { - createChainProvider as createSmoldotChainProvider, - isChainSupported as isSmoldotChainSupported, -} from "@dotli/resolver/chains"; -import { - createCoreRpcChainProvider, - isCoreRpcChainSupported, -} from "@dotli/resolver/rpc-chain"; -import { log } from "@dotli/shared/log"; - -// `createSmoldotChainProvider` returns wrappers around singleton smoldot -// chains. Every wrapper drains the same response queue, so independent core -// connections must share one broker that assigns responses and subscription -// notifications to their owning connection. -const smoldotChainBroker = createChainBrokerManager(createSmoldotChainProvider); - -function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - const id = record.id; - return ( - record.jsonrpc === "2.0" && - typeof record.method === "string" && - (id === undefined || - id === null || - typeof id === "string" || - typeof id === "number") - ); -} - -function toConnection( - provider: JsonRpcProvider | null, -): PlatformJsonRpcConnection { - if (!provider) { - throw new Error("Chain provider unavailable"); - } - const queue: string[] = []; - let wake: (() => void) | null = null; - let stopped = false; - let closed = false; - const conn = provider((message: unknown) => { - if (closed) { - return; - } - queue.push(JSON.stringify(message)); - wake?.(); - wake = null; - }); - const close = (): void => { - if (closed) { - return; - } - stopped = true; - closed = true; - conn.disconnect(); - wake?.(); - wake = null; - }; - - return { - send(request: string): void { - const parsed: unknown = JSON.parse(request); - if (!isJsonRpcRequest(parsed)) { - throw new Error("Invalid JSON-RPC request"); - } - conn.send(parsed); - }, - async *responses(): AsyncIterable { - try { - while (!stopped) { - while (queue.length > 0) { - const response = queue.shift(); - if (response !== undefined) { - yield response; - } - } - await new Promise((resolve) => { - wake = resolve; - }); - } - } finally { - close(); - } - }, - close, - }; -} +import { connectChain } from "@dotli/protocol/client"; export function createChainConnect(): ChainProvider["connect"] { - return (genesisHashBytes) => { - const genesisHash = bytesToHex(genesisHashBytes); - const backend = getBackend(); - if (backend === "rpc-gateway") { - // This callback is shared by product-forwarded calls and core-owned - // Bulletin operations. `featureSupported` is the dApp advertisement; - // this seam cannot enforce that advertised subset. - if (!isCoreRpcChainSupported(genesisHash)) { - log.warn( - `[dot.li truapi-chain] RPC backend doesn't support ${genesisHash}; product call will fail`, - ); - throw new Error(`Unsupported RPC chain: ${genesisHash}`); - } - const connection = toConnection(createCoreRpcChainProvider(genesisHash)); - return Promise.resolve(connection); - } - - if (!isSmoldotChainSupported(genesisHash)) { - log.warn( - `[dot.li truapi-chain] smoldot backend doesn't support ${genesisHash}; product call will fail`, - ); - throw new Error(`Unsupported smoldot chain: ${genesisHash}`); - } - return Promise.resolve( - toConnection(smoldotChainBroker.getLocalProvider(genesisHash)), - ); - }; + return (genesisHashBytes) => connectChain(bytesToHex(genesisHashBytes)); } diff --git a/packages/ui/src/host-callbacks/FeatureSupported.ts b/packages/ui/src/host-callbacks/FeatureSupported.ts index aebc7ef1..b63ffe74 100644 --- a/packages/ui/src/host-callbacks/FeatureSupported.ts +++ b/packages/ui/src/host-callbacks/FeatureSupported.ts @@ -1,14 +1,18 @@ import type { Features } from "@parity/truapi-host"; import { getBackend } from "@dotli/config/mode"; -import { isChainSupported as isSmoldotChainSupported } from "@dotli/resolver/chains"; -import { isRpcChainSupported } from "@dotli/resolver/rpc-chain"; +import { + getActiveGatewaySupportedGenesisHashes, + getActiveSupportedGenesisHashes, +} from "@dotli/config/network"; export function createFeatureSupported(): Features["featureSupported"] { return (request) => { - const supported = + const supportedHashes = getBackend() === "rpc-gateway" - ? isRpcChainSupported(request.value.genesisHash) - : isSmoldotChainSupported(request.value.genesisHash); - return Promise.resolve({ supported }); + ? getActiveGatewaySupportedGenesisHashes() + : getActiveSupportedGenesisHashes(); + return Promise.resolve({ + supported: supportedHashes.has(request.value.genesisHash.toLowerCase()), + }); }; } diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index 8ce231e5..ca2ccefc 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -14,10 +14,7 @@ import { getActiveAppManifest, getActiveRootManifest, } from "@dotli/shared/active-manifest"; -import { - createRemoteChainProvider, - isRemoteChainSupported, -} from "@dotli/protocol/client"; +import { createRemoteChainProvider } from "@dotli/protocol/client"; import { getCacheSettings, setCacheSettings, @@ -1765,13 +1762,7 @@ async function queryFinalizedBlock( genesisHash: string, ): Promise { try { - if (!isRemoteChainSupported(genesisHash)) { - return null; - } const provider = createRemoteChainProvider(genesisHash); - if (provider === null) { - return null; - } const papi = await import("polkadot-api"); const client = papi.createClient(provider); try { diff --git a/packages/ui/tests/chain-callback.test.ts b/packages/ui/tests/chain-callback.test.ts index 89970e88..fa411398 100644 --- a/packages/ui/tests/chain-callback.test.ts +++ b/packages/ui/tests/chain-callback.test.ts @@ -2,41 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getActiveServicesConfig } from "@dotli/config/network"; import { createChainConnect } from "@dotli/ui/host-callbacks/Chain"; -const mocks = vi.hoisted(() => { - const smoldotBrokerProvider = vi.fn(); - return { - backend: "smoldot-shared-worker", - smoldotProvider: vi.fn(), - rpcProvider: vi.fn(), - smoldotBrokerProvider, - createSmoldotChainProvider: vi.fn(), - createRpcChainProvider: vi.fn(), - isSmoldotChainSupported: vi.fn(), - isCoreRpcChainSupported: vi.fn(), - createChainBrokerManager: vi.fn(() => ({ - connectRemote: vi.fn(), - getLocalProvider: smoldotBrokerProvider, - disconnectAll: vi.fn(), - })), - }; -}); - -vi.mock("@dotli/config/mode", () => ({ - getBackend: () => mocks.backend, -})); - -vi.mock("@dotli/resolver/chains", () => ({ - createChainProvider: mocks.createSmoldotChainProvider, - isChainSupported: mocks.isSmoldotChainSupported, +const mocks = vi.hoisted(() => ({ + connectChain: vi.fn(), })); -vi.mock("@dotli/resolver/rpc-chain", () => ({ - createCoreRpcChainProvider: mocks.createRpcChainProvider, - isCoreRpcChainSupported: mocks.isCoreRpcChainSupported, -})); - -vi.mock("@dotli/protocol/broker", () => ({ - createChainBrokerManager: mocks.createChainBrokerManager, +vi.mock("@dotli/protocol/client", () => ({ + connectChain: mocks.connectChain, })); function hexBytes(hex: string): Uint8Array { @@ -51,116 +22,30 @@ function hexBytes(hex: string): Uint8Array { describe("createChainConnect", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.backend = "smoldot-shared-worker"; - mocks.smoldotProvider.mockReturnValue({ - send: vi.fn(), - disconnect: vi.fn(), - }); - mocks.rpcProvider.mockReturnValue({ - send: vi.fn(), - disconnect: vi.fn(), - }); - mocks.createSmoldotChainProvider.mockReturnValue(mocks.smoldotProvider); - mocks.createRpcChainProvider.mockReturnValue(mocks.rpcProvider); - mocks.smoldotBrokerProvider.mockReturnValue(mocks.smoldotProvider); - mocks.isSmoldotChainSupported.mockReturnValue(true); - mocks.isCoreRpcChainSupported.mockReturnValue(true); - }); - - it("As a dotli integrator, the host routes People-chain connections through the selected smoldot backend", async () => { - // Given - const peopleGenesis = getActiveServicesConfig().people.genesis; - - // When - await createChainConnect()(hexBytes(peopleGenesis)); - - // Then - expect(mocks.smoldotBrokerProvider).toHaveBeenCalledWith(peopleGenesis); - expect(mocks.createRpcChainProvider).not.toHaveBeenCalled(); - }); - - it("As a dotli integrator, the host keeps non-People chain connections on the selected smoldot backend", async () => { - // Given - const assetHubGenesis = getActiveServicesConfig().assethub.genesis; - - // When - await createChainConnect()(hexBytes(assetHubGenesis)); - - // Then - expect(mocks.smoldotBrokerProvider).toHaveBeenCalledWith(assetHubGenesis); - expect(mocks.createRpcChainProvider).not.toHaveBeenCalled(); }); - it("As a dotli integrator, the host adapts brokered statement-store traffic to a platform connection", async () => { - // Given - let onMessage: ((message: unknown) => void) | undefined; - const sent: unknown[] = []; - mocks.smoldotProvider.mockImplementation( - (handler: (message: unknown) => void) => { - onMessage = handler; - return { - send: (request: unknown) => { - sent.push(request); - }, - disconnect: vi.fn(), - }; - }, - ); - const assetHubGenesis = getActiveServicesConfig().assethub.genesis; - - const connection = await createChainConnect()(hexBytes(assetHubGenesis)); - const query = { - jsonrpc: "2.0", - id: "opaque-query-request", - method: "statement_subscribeStatement", - params: [{ matchAll: [] }], + it("routes the Rust Core callback through the protocol connection", async () => { + const genesisHash = getActiveServicesConfig().assethub.genesis; + const connection = { + send: vi.fn(), + responses: vi.fn(), + close: vi.fn(), }; + mocks.connectChain.mockResolvedValue(connection); - // When - connection.send(JSON.stringify(query)); - - // Then - expect(sent).toEqual([query]); + const result = await createChainConnect()(hexBytes(genesisHash)); - // When - const ack = { - jsonrpc: "2.0", - id: "opaque-query-request", - result: "remote-sub", - }; - onMessage?.(ack); - - // Then - const responses = connection.responses()[Symbol.asyncIterator](); - expect(JSON.parse((await responses.next()).value)).toEqual(ack); - await responses.return?.(); + expect(mocks.connectChain).toHaveBeenCalledWith(genesisHash); + expect(result).toBe(connection); }); - it("As a dotli integrator, the host does not rewrite core chain RPC requests", async () => { - // Given - const sent: unknown[] = []; - mocks.smoldotProvider.mockImplementation( - (_handler: (message: unknown) => void) => ({ - send: (request: unknown) => { - sent.push(request); - }, - disconnect: vi.fn(), - }), - ); - const assetHubGenesis = getActiveServicesConfig().assethub.genesis; - const connection = await createChainConnect()(hexBytes(assetHubGenesis)); - const unpin = { - jsonrpc: "2.0", - id: "core-unpin", - method: "chainHead_v1_unpin", - params: ["REMOTE-FOLLOW", "0xabc"], - }; - - // When - connection.send(JSON.stringify(unpin)); + it("propagates protocol connection failures", async () => { + const genesisHash = getActiveServicesConfig().bulletin.genesis; + const error = new Error("Unsupported chain"); + mocks.connectChain.mockRejectedValue(error); - // Then - expect(sent).toEqual([unpin]); - connection.close(); + await expect(createChainConnect()(hexBytes(genesisHash))).rejects.toBe( + error, + ); }); }); diff --git a/packages/ui/tests/feature-supported.test.ts b/packages/ui/tests/feature-supported.test.ts new file mode 100644 index 00000000..a0562e05 --- /dev/null +++ b/packages/ui/tests/feature-supported.test.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + NetworkName, + getActiveServicesConfig, + setNetworkOverride, +} from "@dotli/config/network"; +import { createFeatureSupported } from "@dotli/ui/host-callbacks/FeatureSupported"; + +const mocks = vi.hoisted(() => ({ + backend: "rpc-gateway", +})); + +vi.mock("@dotli/config/mode", () => ({ + getBackend: () => mocks.backend, +})); + +describe("product chain feature support", () => { + beforeEach(() => { + setNetworkOverride(NetworkName.PASEO_NEXT_V2); + mocks.backend = "rpc-gateway"; + }); + + it("keeps Bulletin internal in RPC gateway mode", async () => { + const result = await createFeatureSupported()({ + value: { genesisHash: getActiveServicesConfig().bulletin.genesis }, + }); + + expect(result).toEqual({ supported: false }); + }); + + it("reports Bulletin support in smoldot mode", async () => { + mocks.backend = "smoldot-shared-worker"; + + const result = await createFeatureSupported()({ + value: { genesisHash: getActiveServicesConfig().bulletin.genesis }, + }); + + expect(result).toEqual({ supported: true }); + }); +});