From 846e07f9de564373e7bb56549d64bf53743f96c5 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 1/9] feat(topbar): explain statement-store login failures and harden the permissions popover Map slot-exhaustion, rejected-transaction, and JSON-RPC registration failures to actionable copy with the raw reason as a detail line, and guard the async permissions popover against stale renders with an unavailable-state fallback. --- packages/ui/src/topbar.ts | 52 +++++++++++++++++++++++++++++--- packages/ui/tests/topbar.test.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index 73ed696..09406ca 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -439,7 +439,32 @@ const PENDING_ICON_SVG = // fall back to the raw error. function friendlyAuthError( message: string, -): { title: string; subtitle: string } | null { +): { title: string; subtitle: string; detail?: string } | null { + if ( + message.includes("no free statement-store slot for device registration") + ) { + return { + title: "No Statement Store slots left", + subtitle: "Polkadot Mobile could not register this browser as a device.", + detail: "no free statement-store slot for device registration", + }; + } + if (message.includes("Invalid Transaction")) { + return { + title: "Statement Store transaction rejected", + subtitle: + "Polkadot Mobile could not register this browser because the chain rejected the registration transaction.", + detail: message, + }; + } + if (message.includes("SubstrateSdk.JSONRPCError error 1")) { + return { + title: "Statement Store registration failed", + subtitle: + "Polkadot Mobile reported a JSON-RPC failure while registering this browser as a device.", + detail: message, + }; + } if (message.includes("OriginPersonProviderError")) { return { title: "Your account is still being set up", @@ -469,6 +494,13 @@ function renderError(message: string): void { subtitle.className = "auth-modal-pending-subtitle"; subtitle.textContent = friendly.subtitle; container.appendChild(subtitle); + + if (friendly.detail !== undefined && friendly.detail.length > 0) { + const detail = document.createElement("p"); + detail.className = "auth-modal-error"; + detail.textContent = friendly.detail; + container.appendChild(detail); + } } else { const msg = document.createElement("p"); msg.className = "auth-modal-error"; @@ -632,6 +664,7 @@ const STATUS_LABELS: Record = { const STATUS_ORDER: readonly PermissionStatus[] = ["ask", "granted", "denied"]; let openDropdownCleanup: (() => void) | null = null; +let permissionsRenderToken = 0; function closeOpenDropdown(): void { openDropdownCleanup?.(); @@ -639,12 +672,20 @@ function closeOpenDropdown(): void { } function renderPermissionsPopover(): void { - void renderPermissionsPopoverAsync().catch(() => { + const token = ++permissionsRenderToken; + void renderPermissionsPopoverAsync(token).catch(() => { + if (token !== permissionsRenderToken) { + return; + } permissionsPopoverList.innerHTML = ""; + const hint = document.createElement("div"); + hint.className = "permissions-popover-footer"; + hint.textContent = "Permissions are unavailable for this app."; + permissionsPopoverList.appendChild(hint); }); } -async function renderPermissionsPopoverAsync(): Promise { +async function renderPermissionsPopoverAsync(token: number): Promise { closeOpenDropdown(); permissionsPopoverList.innerHTML = ""; @@ -666,7 +707,10 @@ async function renderPermissionsPopoverAsync(): Promise { for (const [index, perm] of ALL_PERMISSIONS.entries()) { const status = statuses[index] ?? "ask"; - if (currentProductLabel !== productLabel) { + if ( + token !== permissionsRenderToken || + currentProductLabel !== productLabel + ) { return; } diff --git a/packages/ui/tests/topbar.test.ts b/packages/ui/tests/topbar.test.ts index eb48960..ec0ae6c 100644 --- a/packages/ui/tests/topbar.test.ts +++ b/packages/ui/tests/topbar.test.ts @@ -343,6 +343,48 @@ describe("topbar login cancellation", () => { "Retry", ); }); + + it("explains statement-store slot exhaustion in the login failure view", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + reason: "no free statement-store slot for device registration", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("No Statement Store slots left"); + expect(modalText).toContain( + "no free statement-store slot for device registration", + ); + expect(modalText).toContain("Retry"); + }); + + it("explains rejected statement-store transactions from the raw reason", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + reason: "submit RPC error: Invalid Transaction", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Statement Store transaction rejected"); + expect(modalText).toContain("submit RPC error: Invalid Transaction"); + expect(modalText).toContain("Retry"); + }); }); describe("topbar boot rehydration", () => { From 51ee0ac5a5af7bb68b6364dbd18c72b78821ef63 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 2/9] feat(debug): tap TrUAPI wire frames for the debug panel Wrap each core provider so every inbound/outbound wire frame is decoded and emitted on the dotli debug bus when the panel is listening, restoring per-frame protocol traffic in the timeline. --- packages/ui/src/bridge.ts | 95 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts index 3a4d838..b139ade 100644 --- a/packages/ui/src/bridge.ts +++ b/packages/ui/src/bridge.ts @@ -31,7 +31,10 @@ import { getNetwork } from "@dotli/config/network"; import { m } from "@dotli/metrics/metrics"; import * as S from "@dotli/metrics/spans"; import { log } from "@dotli/shared/log"; -import { emitDotliDebugEvent } from "@dotli/truapi-debug/dotli-debug-bus"; +import { + emitDotliDebugEvent, + hasDotliDebugListeners, +} from "@dotli/truapi-debug/dotli-debug-bus"; import type { TrUApiProductProvider } from "@parity/truapi-host"; import type { PairingHostAdmin } from "@parity/truapi-host"; import { @@ -436,6 +439,91 @@ function pipeProviders( }; } +function emitWireFrameDebug( + direction: "incoming" | "outgoing", + productId: string, + message: Uint8Array, +): void { + if (!hasDotliDebugListeners()) { + return; + } + const decoded = decodeWireMessage(message); + if (decoded.isErr()) { + return; + } + const wireId = decoded.value.payload.id; + emitDotliDebugEvent({ + kind: "truapi", + direction, + productId, + requestId: decoded.value.requestId, + payload: { + tag: `wire_${String(wireId)}`, + value: { + wireId, + bytes: decoded.value.payload.value, + }, + }, + }); +} + +function wrapCoreProviderForDebug( + provider: CoreProviderBase, + productId: string, +): CoreProviderBase { + const listeners = new Set<(message: Uint8Array) => void>(); + let disposed = false; + const unsubscribeCore = provider.subscribe((message) => { + if (disposed) { + return; + } + emitWireFrameDebug("outgoing", productId, message); + for (const listener of [...listeners]) { + listener(message); + } + }); + + return { + postMessage(message: Uint8Array): void { + if (disposed) { + return; + } + emitWireFrameDebug("incoming", productId, message); + provider.postMessage(message); + }, + subscribe(callback) { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }, + subscribeClose(callback) { + return provider.subscribeClose?.(callback) ?? noop; + }, + async disconnectSession() { + await provider.disconnectSession(); + }, + getPermissionAuthorizationStatus(request) { + return provider.getPermissionAuthorizationStatus(request); + }, + getPermissionAuthorizationStatuses(requests) { + return provider.getPermissionAuthorizationStatuses(requests); + }, + setPermissionAuthorizationStatus(request, status) { + return provider.setPermissionAuthorizationStatus(request, status); + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + unsubscribeCore(); + listeners.clear(); + provider.dispose(); + }, + }; +} + let topbarLoginRequestSeq = 0; export function requestCoreLogin( @@ -644,7 +732,10 @@ async function createCoreProvider( }, ); const provider = await runtime.createProvider({ productId }); - return trackCoreProvider(provider, runtime); + return trackCoreProvider( + wrapCoreProviderForDebug(provider, options.productId ?? label), + runtime, + ); } async function getLandingAuthHost(): Promise { From 638f61e64b966c19de01e000019828ca017534ae Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 3/9] feat(ui): encrypt allowance session keys at rest AllowanceKeys slots are AES-GCM encrypted before hitting localStorage. Known issues to resolve before merge: derive a random per-write nonce (the current fixed nonce is obfuscation, not encryption) and accept the plain-hex format written before this change as a read fallback. --- bun.lock | 1 + packages/ui/package.json | 1 + .../ui/src/host-callbacks/SessionStore.ts | 35 +++++++++++++++++-- packages/ui/tests/session-store.test.ts | 4 +-- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 84758eb..d6ef82d 100644 --- a/bun.lock +++ b/bun.lock @@ -294,6 +294,7 @@ "@dotli/shared": "workspace:*", "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", + "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "@parity/truapi": "0.4.0", "@parity/truapi-host": "0.1.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9c26349..7da2abf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ "@dotli/shared": "workspace:*", "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", + "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "@parity/truapi": "0.4.0", "@parity/truapi-host": "0.1.0", diff --git a/packages/ui/src/host-callbacks/SessionStore.ts b/packages/ui/src/host-callbacks/SessionStore.ts index 3c36dc6..a6e0dfa 100644 --- a/packages/ui/src/host-callbacks/SessionStore.ts +++ b/packages/ui/src/host-callbacks/SessionStore.ts @@ -1,4 +1,6 @@ import { SITE_ID } from "@dotli/config/config"; +import { gcm } from "@noble/ciphers/aes.js"; +import { blake2b } from "@noble/hashes/blake2.js"; import { bytesToHex, hexToBytes } from "@parity/truapi/scale"; import { encodeCoreStorageKey } from "@parity/truapi-host"; import type { @@ -18,6 +20,7 @@ import { dispatchAuthState } from "./AuthState"; const LOCAL_CHANGE_EVENT = "dotli:truapi-session-store-changed"; const CORE_LOCAL_STORAGE_PREFIX = "dotli:core:"; +const textEncoder = new TextEncoder(); // JSON cache of the last connected UI state the core reported via // `authStateChanged`. Lives in shared auth storage next to the opaque @@ -153,7 +156,7 @@ async function readCoreStorageValue( return hexToBytes(raw); } const raw = localStorage.getItem(coreLocalStorageKey(key)); - return raw === null ? undefined : hexToBytes(raw); + return raw === null ? undefined : decodeCoreStorageValue(key, raw); } async function writeCoreStorageValue( @@ -169,7 +172,10 @@ async function writeCoreStorageValue( emitLocalChange(); return; } - localStorage.setItem(coreLocalStorageKey(key), bytesToHex(value)); + localStorage.setItem( + coreLocalStorageKey(key), + encodeCoreStorageValue(key, value), + ); } async function clearCoreStorageValue(key: CoreStorageKey): Promise { @@ -199,6 +205,31 @@ function coreLocalStorageKey(key: CoreStorageKey): string { } } +function encodeCoreStorageValue( + key: CoreStorageKey, + value: Uint8Array, +): string { + if (key.tag === "AllowanceKeys") { + return bytesToHex(allowanceStorageCipher().encrypt(value)); + } + return bytesToHex(value); +} + +function decodeCoreStorageValue(key: CoreStorageKey, raw: string): Uint8Array { + const bytes = hexToBytes(raw); + if (key.tag === "AllowanceKeys") { + return allowanceStorageCipher().decrypt(bytes); + } + return bytes; +} + +function allowanceStorageCipher(): ReturnType { + return gcm( + blake2b(textEncoder.encode(SITE_ID), { dkLen: 16 }), + blake2b(textEncoder.encode("nonce"), { dkLen: 32 }), + ); +} + function hexNoPrefix(bytes: Uint8Array): string { return bytesToHex(bytes).slice(2); } diff --git a/packages/ui/tests/session-store.test.ts b/packages/ui/tests/session-store.test.ts index 0014092..b14d97e 100644 --- a/packages/ui/tests/session-store.test.ts +++ b/packages/ui/tests/session-store.test.ts @@ -150,7 +150,7 @@ describe("session-store host callbacks", () => { expect(localStorage.length).toBe(1); }); - it("round-trips persisted allowance key slots", async () => { + it("encrypts persisted allowance key slots", async () => { const { readCoreStorage, writeCoreStorage, clearCoreStorage } = createSessionStoreAdapters(); const key = { @@ -161,7 +161,7 @@ describe("session-store host callbacks", () => { await writeCoreStorage(key, new Uint8Array([1, 2, 3, 4])); const storageKey = "dotli:core:allowance-keys:session-1"; - expect(localStorage.getItem(storageKey)).toBe("0x01020304"); + expect(localStorage.getItem(storageKey)).not.toBe("0x01020304"); expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([ 1, 2, 3, 4, ]); From d2defc50e62fdec173d0832d8be9a28bf2638e33 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 4/9] feat(ui): rate-limit permission and notification prompts Prompt-driven host callbacks share a 20-per-10s sliding window so a hostile product cannot flood the host with modal prompts. --- .../ui/src/host-callbacks/PromptPermission.ts | 10 ++++++++- .../ui/src/host-callbacks/PushNotification.ts | 3 +++ packages/ui/src/host-callbacks/rate-limit.ts | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/host-callbacks/rate-limit.ts diff --git a/packages/ui/src/host-callbacks/PromptPermission.ts b/packages/ui/src/host-callbacks/PromptPermission.ts index 635971b..3c25d0f 100644 --- a/packages/ui/src/host-callbacks/PromptPermission.ts +++ b/packages/ui/src/host-callbacks/PromptPermission.ts @@ -14,6 +14,7 @@ import { } from "../permissions"; import { showPermissionRequestModal } from "../permission-modal"; import { showNotification } from "../notification"; +import { createSubmitRateLimiter } from "./rate-limit"; // Remote tags that don't reach a host enforcement point: WebRtc is gated // by the iframe `allow` attribute, and `Remote` (HTTP/WS) can't be @@ -33,6 +34,7 @@ function gatedRemotePermissionName( } export function createPromptPermission(label: string): Permissions { + const limiter = createSubmitRateLimiter(); const devicePermission: Permissions["devicePermission"] = async (tag) => { // OpenUrl has no host-side enforcement point; auto-grant rather than show // a modal whose deny button cannot block the underlying browser API. @@ -42,6 +44,7 @@ export function createPromptPermission(label: string): Permissions { return { granted: await decidePromptPermission(label, tag, { kind: "Device", + limiter, reloadOnGrant: isDevicePermission(tag), }), }; @@ -55,6 +58,7 @@ export function createPromptPermission(label: string): Permissions { return { granted: await decidePromptPermission(label, name, { kind: "Remote", + limiter, }), }; }; @@ -67,10 +71,11 @@ export async function decidePromptPermission( name: EnforceablePermissionName, options: { kind: "Device" | "Remote"; + limiter: { allow: () => boolean }; reloadOnGrant?: boolean; }, ): Promise { - const { kind, reloadOnGrant = false } = options; + const { kind, limiter, reloadOnGrant = false } = options; const status = await getPermissionStatus(label, name); if (status === "granted") { return true; @@ -88,6 +93,9 @@ export async function decidePromptPermission( return false; } // status === "ask": show the modal and wait for the user. + if (!limiter.allow()) { + throw new Error("Permission prompt rate limited"); + } const decision = await showPermissionRequestModal(label, name); if (decision === "dismissed") { throw new Error("User dismissed permission dialog"); diff --git a/packages/ui/src/host-callbacks/PushNotification.ts b/packages/ui/src/host-callbacks/PushNotification.ts index 5b502f6..8735e39 100644 --- a/packages/ui/src/host-callbacks/PushNotification.ts +++ b/packages/ui/src/host-callbacks/PushNotification.ts @@ -9,10 +9,12 @@ import { } from "../scheduled-notifications"; import { showNotification } from "../notification"; import { decidePromptPermission } from "./PromptPermission"; +import { createSubmitRateLimiter } from "./rate-limit"; export function createNotificationAdapters( label: string, ): Required { + const limiter = createSubmitRateLimiter(); const pushNotification: Required["pushNotification"] = async ({ text, deeplink, @@ -26,6 +28,7 @@ export function createNotificationAdapters( const granted = await decidePromptPermission(label, "Notifications", { kind: "Device", + limiter, }); if (!granted) { throw new Error("Notifications permission denied"); diff --git a/packages/ui/src/host-callbacks/rate-limit.ts b/packages/ui/src/host-callbacks/rate-limit.ts new file mode 100644 index 0000000..249daaf --- /dev/null +++ b/packages/ui/src/host-callbacks/rate-limit.ts @@ -0,0 +1,21 @@ +// Sliding-window rate limiter shared across host callbacks. + +const SUBMIT_WINDOW_MS = 10_000; +const SUBMIT_MAX_PER_WINDOW = 20; + +export function createSubmitRateLimiter(): { allow: () => boolean } { + const timestamps: number[] = []; + return { + allow() { + const now = Date.now(); + while (timestamps.length > 0 && timestamps[0] <= now - SUBMIT_WINDOW_MS) { + timestamps.shift(); + } + if (timestamps.length >= SUBMIT_MAX_PER_WINDOW) { + return false; + } + timestamps.push(now); + return true; + }, + }; +} From 8cb86979880880d9e0ee80cf255a5415d04ca26d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 5/9] fix(debug): panel offset, terminator matching, and config dependency Align the docked panel with the 56px topbar iframe, close system flows by exact layer:event names so setup_ready no longer false-matches the ready suffix, and declare the @dotli/config dependency that chain-registry already imports. --- bun.lock | 1 + packages/truapi-debug/package.json | 1 + packages/truapi-debug/src/panel.ts | 2 +- packages/truapi-debug/src/timeline-layout.ts | 32 +++++++++++--------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index d6ef82d..3b65e64 100644 --- a/bun.lock +++ b/bun.lock @@ -267,6 +267,7 @@ "name": "@dotli/truapi-debug", "version": "0.6.0", "dependencies": { + "@dotli/config": "workspace:*", "@dotli/shared": "workspace:*", "nanoevents": "^9.1.0", }, diff --git a/packages/truapi-debug/package.json b/packages/truapi-debug/package.json index be0b89c..5b62341 100644 --- a/packages/truapi-debug/package.json +++ b/packages/truapi-debug/package.json @@ -19,6 +19,7 @@ "vite": "^8.0.16" }, "dependencies": { + "@dotli/config": "workspace:*", "@dotli/shared": "workspace:*", "nanoevents": "^9.1.0" } diff --git a/packages/truapi-debug/src/panel.ts b/packages/truapi-debug/src/panel.ts index be30477..687ab4d 100644 --- a/packages/truapi-debug/src/panel.ts +++ b/packages/truapi-debug/src/panel.ts @@ -194,7 +194,7 @@ function adjustIframeForPanel(panel: HTMLElement, state: PanelState): void { return; } const hasTopbar = document.getElementById("topbar") !== null; - const topOffset = hasTopbar ? 40 : 0; + const topOffset = hasTopbar ? 56 : 0; if (state.dock === "right") { iframe.style.height = `calc(100vh - ${String(topOffset)}px)`; // When collapsed, the 32px header bar overlays the top-right corner diff --git a/packages/truapi-debug/src/timeline-layout.ts b/packages/truapi-debug/src/timeline-layout.ts index 5e9881b..f3533a1 100644 --- a/packages/truapi-debug/src/timeline-layout.ts +++ b/packages/truapi-debug/src/timeline-layout.ts @@ -652,7 +652,7 @@ function segmentForGroup( * point-in-time system events (boot phases, failover decisions, etc.) * still occupy a lane position at the right Y. `pending` is true for * flows whose first event is a "start" kind without a matching end - * event in the buffer. See SYSTEM_TERMINATOR_SUFFIXES. + * event in the buffer. See `isSystemFlowTerminator`. */ function systemSegmentForGroup( group: StoredSystemEvent[], @@ -688,22 +688,21 @@ function systemSegmentDetail( return `${first.layer}·${String(all.length)} step${all.length === 1 ? "" : "s"}`; } -/** Events that close a multi-step system flow. Mirrors the pairs we - * document in the detail pane so visual pending state matches intent. */ +/** Exact layer:event names that close a multi-step system flow. */ +const SYSTEM_TERMINATOR_EVENTS: ReadonlySet = new Set([ + "boot:ready", + "boot:landing_page_shown", + "bridge:first_outbound", + "render:iframe_ready", + "sandbox:document_written", + "main:monitor_stopped", +]); + +/** Suffixes that close error/completion families without listing every event. */ const SYSTEM_TERMINATOR_SUFFIXES: readonly string[] = [ - "ready", "failed", - "landing_page_shown", "completed", "terminated", - "iframe_ready", - // `setup_ready` does not close the bridge flow. It only means the - // host is listening, and the product can stay silent for many seconds - // after that (iframe still loading, sandbox relay not ready). The - // bridge flow stays open until bidirectional traffic is observed via - // `first_outbound`. - "first_outbound", - "document_written", "peer_action_processed", "peer_action_failed", "host_action_response_received", @@ -712,10 +711,13 @@ const SYSTEM_TERMINATOR_SUFFIXES: readonly string[] = [ "resolve_failed", ]; -function isSystemFlowTerminator(ev: StoredSystemEvent): boolean { +export function isSystemFlowTerminator(ev: StoredSystemEvent): boolean { const event = ev.event; + if (SYSTEM_TERMINATOR_EVENTS.has(`${ev.layer}:${event}`)) { + return true; + } return SYSTEM_TERMINATOR_SUFFIXES.some( - (suf) => event === suf || event.endsWith(`_${suf}`) || event === suf, + (suf) => event === suf || event.endsWith(`_${suf}`), ); } From e1e2bc7dd05d0c5b69260b1a61253892b40b20c7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 6/9] chore(resolver): drop unused dependencies and add chain coverage tests Remove @polkadot-api/signer and @polkadot-labs/hdkd{,-helpers}, which nothing in the resolver imports, and cover isChainSupported and the pre-existing rpc-chain provider behavior with unit tests. --- bun.lock | 15 --------------- packages/resolver/package.json | 3 --- packages/resolver/tests/chains.test.ts | 15 +++++++++++++++ packages/resolver/tests/rpc-chain.test.ts | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 packages/resolver/tests/chains.test.ts diff --git a/bun.lock b/bun.lock index 3b65e64..283a6d3 100644 --- a/bun.lock +++ b/bun.lock @@ -196,12 +196,9 @@ "@noble/hashes": "^2.2.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", - "@polkadot-api/signer": "^0.3.3", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", - "@polkadot-labs/hdkd": "^0.0.28", - "@polkadot-labs/hdkd-helpers": "^0.0.30", "polkadot-api": "^2.1.6", "smoldot": "^3.1.4", }, @@ -656,8 +653,6 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], @@ -716,10 +711,6 @@ "@polkadot-api/ws-provider": ["@polkadot-api/ws-provider@0.9.0", "", { "dependencies": { "@polkadot-api/json-rpc-provider": "0.2.0", "@polkadot-api/json-rpc-provider-proxy": "0.4.0", "@polkadot-api/utils": "0.4.0" }, "peerDependencies": { "rxjs": ">=7.8.0" } }, "sha512-czTLgHEJPqx+6t5sb5OJpXDaSmbDTr8zYngBdUI47QYqcNB225zo/GdYakiNiGThtxVp1MLK7QsNXcT6XgqQNQ=="], - "@polkadot-labs/hdkd": ["@polkadot-labs/hdkd@0.0.28", "", { "dependencies": { "@polkadot-labs/hdkd-helpers": "~0.0.29" } }, "sha512-LpdqtQRpcgZQ5Mr8J0ddMA5ZufsbI4W3KuJkVdoYMnSmWs4179LigDb1rTYAOtyCg2jWUjf7rWP0mGxQQvNrHw=="], - - "@polkadot-labs/hdkd-helpers": ["@polkadot-labs/hdkd-helpers@0.0.30", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/sr25519": "^1.0.0", "scale-ts": "^1.6.1" } }, "sha512-qWmmD6ayj14RenDuDFfjF3sHS7ObqPzwIIMPcSVoDeKFSeQV7RY0HwyhC5CG4i6FoguMzak2dbtjYpNN5XQiwQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], @@ -816,8 +807,6 @@ "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], - "@scure/sr25519": ["@scure/sr25519@1.0.0", "", { "dependencies": { "@noble/curves": "~2.0.0", "@noble/hashes": "~2.0.0" } }, "sha512-b+uhK5akMINXZP95F3gJGcb5CMKYxf+q55fwMl0GoBwZDbWolmGNi1FrBSwuaZX5AhqS2byHiAueZgtDNpot2A=="], - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" } }, "sha512-tXObp954rMTSYKlbftjVXHtNl4t/6ssks3jkqyzmKb+PDPWzabGQO7sWwqVuTjT8Kx/8A3FmriS1bGmqxiJy3A=="], @@ -1752,10 +1741,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@scure/sr25519/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], - - "@scure/sr25519/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - "@sentry/bundler-plugin-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@sentry/bundler-plugin-core/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], diff --git a/packages/resolver/package.json b/packages/resolver/package.json index 6b5e9c0..421b755 100644 --- a/packages/resolver/package.json +++ b/packages/resolver/package.json @@ -31,12 +31,9 @@ "@ensdomains/content-hash": "^3.0.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", - "@polkadot-api/signer": "^0.3.3", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", - "@polkadot-labs/hdkd": "^0.0.28", - "@polkadot-labs/hdkd-helpers": "^0.0.30", "polkadot-api": "^2.1.6", "smoldot": "^3.1.4" } diff --git a/packages/resolver/tests/chains.test.ts b/packages/resolver/tests/chains.test.ts new file mode 100644 index 0000000..1355544 --- /dev/null +++ b/packages/resolver/tests/chains.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getActiveSupportedGenesisHashes } from "@dotli/config/network"; +import { isChainSupported } from "@dotli/resolver/chains"; + +describe("isChainSupported", () => { + it("accepts every chain the host runtime can expose", () => { + for (const genesisHash of getActiveSupportedGenesisHashes()) { + expect(isChainSupported(genesisHash)).toBe(true); + } + }); + + it("rejects unknown genesis hashes", () => { + expect(isChainSupported("0xdeadbeef")).toBe(false); + }); +}); diff --git a/packages/resolver/tests/rpc-chain.test.ts b/packages/resolver/tests/rpc-chain.test.ts index 7ea4f17..7b35584 100644 --- a/packages/resolver/tests/rpc-chain.test.ts +++ b/packages/resolver/tests/rpc-chain.test.ts @@ -16,6 +16,25 @@ vi.mock("polkadot-api/ws", () => ({ })); describe("rpc-chain", () => { + it("supports the active People chain when RPC endpoints are configured", () => { + const people = getActiveServicesConfig().people; + + expect(isRpcChainSupported(people.genesis)).toBe(true); + + const provider = {}; + mocks.getWsProvider.mockReturnValueOnce(provider); + + expect(createRpcChainProvider(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(); + }); + it("reserves Bulletin RPC access for the host-owned Rust core", () => { const bulletin = getActiveServicesConfig().bulletin; expect(isRpcChainSupported(bulletin.genesis)).toBe(false); From 1b137b3b0114540c46901cc0ea71a3a865eb4c40 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 16:10:26 +0200 Subject: [PATCH 7/9] fix(protocol): surface chainSend flush failures as JSON-RPC errors Failed sends flushed after chainConnect resolve to JSON-RPC error responses, matching the direct send path, so polkadot-api's client sees the failure instead of hanging. --- packages/protocol/src/client.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index b6df73f..de3baa1 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -737,6 +737,14 @@ export function createRemoteChainProvider( void postRequest("chainSend", { connectionId, message: JSON.stringify(message), + }).catch((error: unknown) => { + const errResponse = buildJsonRpcError( + message, + serializeError(error), + ); + if (errResponse !== null) { + onMessage(errResponse); + } }); } remote.pendingMessages = []; From 0c8ea2efc15eaf11a90b3df409d9338d7f51ada0 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 13 Jul 2026 22:54:55 +0200 Subject: [PATCH 8/9] fix(ui): keep preimage lookup polling Backend read failures no longer push a terminal error into the preimage lookup subscription, so the stream keeps polling and slow Bulletin/IPFS propagation surfaces as a late result instead of a permanent failure. Gateway mode logs that sandbox bitswap is unavailable rather than advertising the Bulletin chain. --- packages/ui/src/bulletin-bitswap.ts | 9 +++++++- packages/ui/src/host-callbacks/Preimage.ts | 6 +---- packages/ui/tests/preimage.test.ts | 26 +++++++++++++--------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/bulletin-bitswap.ts b/packages/ui/src/bulletin-bitswap.ts index 06dca7c..143d8e0 100644 --- a/packages/ui/src/bulletin-bitswap.ts +++ b/packages/ui/src/bulletin-bitswap.ts @@ -12,6 +12,7 @@ import { isRemoteChainSupported, } from "@dotli/protocol/client"; import { isSandboxOrigin } from "@dotli/config/config"; +import { getBackend } from "@dotli/config/mode"; import { getActiveServicesConfig } from "@dotli/config/network"; import { log } from "@dotli/shared/log"; import { serializeError } from "@dotli/shared/errors"; @@ -200,7 +201,13 @@ function isBitswapGetMessage(value: unknown): value is BitswapGetMessage { /** Idempotent. Call once at host startup. */ export function listenForSandboxBitswap(): void { - if (!isRemoteChainSupported(getActiveServicesConfig().bulletin.genesis)) { + 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.", ); diff --git a/packages/ui/src/host-callbacks/Preimage.ts b/packages/ui/src/host-callbacks/Preimage.ts index c2ff22a..1bf06c4 100644 --- a/packages/ui/src/host-callbacks/Preimage.ts +++ b/packages/ui/src/host-callbacks/Preimage.ts @@ -6,7 +6,6 @@ import type { PreimageHost } from "@parity/truapi-host"; import { hashToCid } from "@dotli/content/preimage"; import { fetchFromIpfs } from "@dotli/content/ipfs"; import { getBackend } from "@dotli/config/mode"; -import { serializeError } from "@dotli/shared/errors"; import { log } from "@dotli/shared/log"; import { bitswapGet } from "../bulletin-bitswap"; import { toHex } from "@dotli/shared/hex"; @@ -35,7 +34,7 @@ function createPreimageLookupSubscribe( let stopped = false; return createResultStream( [undefined], - (push, pushError) => { + (push, _pushError) => { let intervalId: ReturnType | null = null; let initialTimeoutId: ReturnType | null = null; const stopPolling = (): void => { @@ -83,9 +82,6 @@ function createPreimageLookupSubscribe( } } catch (err) { log.warn(`[${label}] preimage lookup via ${backend} failed:`, err); - pushError({ - reason: `preimage lookup via ${backend} failed: ${serializeError(err)}`, - }); } }; diff --git a/packages/ui/tests/preimage.test.ts b/packages/ui/tests/preimage.test.ts index 73c04dc..9a5e87a 100644 --- a/packages/ui/tests/preimage.test.ts +++ b/packages/ui/tests/preimage.test.ts @@ -40,33 +40,39 @@ describe("preimage host callbacks", () => { expect("submitPreimage" in adapters).toBe(false); }); - it("emits lookup backend failures as stream errors", async () => { + it("keeps polling after transient lookup backend failures", async () => { vi.useFakeTimers(); try { const { lookupPreimage } = createPreimageAdapters("myapp"); const missingKey = new Uint8Array(32); + const found = new Uint8Array([1, 2, 3]); mocks.fetchFromIpfs.mockRejectedValueOnce( new Error("gateway unavailable"), ); + mocks.fetchFromIpfs.mockResolvedValueOnce({ data: found }); const iterator = lookupPreimage(missingKey)[Symbol.asyncIterator](); const first = await iterator.next(); const secondPromise = iterator.next(); + let secondSettled = false; + void secondPromise.then(() => { + secondSettled = true; + }); await vi.advanceTimersByTimeAsync(1000); - const second = await secondPromise; - const done = await iterator.next(); expect(first.done).toBe(false); expect(first.value.isOk()).toBe(true); expect(first.value._unsafeUnwrap()).toBeUndefined(); - expect(second.done).toBe(false); - expect(second.value.isErr()).toBe(true); - expect(second.value._unsafeUnwrapErr().reason).toContain( - "preimage lookup via rpc-gateway failed: gateway unavailable", - ); - await vi.advanceTimersByTimeAsync(10_000); + expect(secondSettled).toBe(false); expect(mocks.fetchFromIpfs).toHaveBeenCalledTimes(1); - expect(done.done).toBe(true); + + await vi.advanceTimersByTimeAsync(9000); + const second = await secondPromise; + expect(second.done).toBe(false); + expect(second.value.isOk()).toBe(true); + expect(second.value._unsafeUnwrap()).toEqual(found); + expect(mocks.fetchFromIpfs).toHaveBeenCalledTimes(2); + await iterator.return?.(); } finally { vi.useRealTimers(); } From 77285088a0a78824f32c86332bd7ec49a16d0e0b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 15 Jul 2026 11:03:57 +0200 Subject: [PATCH 9/9] fix(protocol): preserve shared subscriptions Ref-count upstream tokens shared by local sessions and buffer subscription events that arrive before their token response. Broker transport hardening kept separate from the Rust host migration. --- packages/protocol/src/broker.ts | 248 +++++++++++++++++----- packages/protocol/tests/broker.test.ts | 276 +++++++++++++++++++++++++ 2 files changed, 467 insertions(+), 57 deletions(-) diff --git a/packages/protocol/src/broker.ts b/packages/protocol/src/broker.ts index 0eb8e77..64e7b07 100644 --- a/packages/protocol/src/broker.ts +++ b/packages/protocol/src/broker.ts @@ -53,6 +53,7 @@ interface PendingRequest { interface OwnedToken { sessionId: string; localToken: string; + upstreamToken: string; releaseMethod: string; } @@ -108,8 +109,12 @@ interface BrokerConnection { const TOKEN_METHODS = new Map([ ["transaction_v1_broadcast", "transaction_v1_stop"], + ["transactionWatch_v1_submitAndWatch", "transactionWatch_v1_unwatch"], ["statement_subscribeStatement", "statement_unsubscribeStatement"], ]); +const RELEASE_METHODS = new Set(TOKEN_METHODS.values()); +const MAX_EARLY_SUBSCRIPTION_TOKENS = 32; +const MAX_EARLY_SUBSCRIPTION_EVENTS_PER_TOKEN = 16; function isJsonRpcObject( value: unknown, @@ -189,6 +194,10 @@ function cloneWithRewrittenFirstParam( return { ...request, params }; } +function releaseResultFor(method: string): unknown { + return method === "statement_unsubscribeStatement" ? true : null; +} + export interface ChainBrokerManager { connectRemote( genesisHash: string, @@ -227,7 +236,11 @@ class ChainBroker { private readonly sessions = new Map(); private readonly pending = new Map(); private readonly localToOwned = new Map(); - private readonly upstreamToOwned = new Map(); + private readonly upstreamToOwned = new Map>(); + private readonly earlySubscriptions = new Map< + string, + SubscriptionMessage[] + >(); private readonly localFollowTokens = new Map< string, { sessionId: string; followKey: string } @@ -375,10 +388,16 @@ class ChainBroker { /** Rewrite a session-owned token to its upstream token and forward. */ private routeGenericRequest(session: Session, request: JsonRpcRequest): void { + const method = request.method as string; + if (RELEASE_METHODS.has(method)) { + this.routeOwnedReleaseRequest(session, request, method); + return; + } + const rewritten = this.rewriteOwnedToken(session, request); if (rewritten === null) { brokerLog( - `routeGenericRequest: unknown token for session ${session.id}, method=${request.method as string}`, + `routeGenericRequest: unknown token for session ${session.id}, method=${method}`, ); this.sendToSession( session, @@ -397,7 +416,64 @@ class ChainBroker { this.pending.set(upstreamId, { sessionId: session.id, clientId: request.id ?? null, - method: request.method as string, + method, + }); + this.sendUpstream({ ...rewritten, id: upstreamId }); + } + + private routeOwnedReleaseRequest( + session: Session, + request: JsonRpcRequest, + method: string, + ): void { + const params = Array.isArray(request.params) ? request.params : []; + const localToken = typeof params[0] === "string" ? params[0] : null; + const owned = + localToken !== null ? this.localToOwned.get(localToken) : undefined; + if ( + localToken === null || + owned?.sessionId !== session.id || + owned.releaseMethod !== method + ) { + this.sendToSession( + session, + buildJsonRpcError(request.id ?? null, "Unknown subscription/token"), + ); + return; + } + + const upstreamToken = owned.upstreamToken; + const released = this.releaseOwnedToken(localToken, false); + if (released === null) { + this.sendToSession( + session, + buildJsonRpcError(request.id ?? null, "Unknown subscription/token"), + ); + return; + } + + if (!released.lastOwner) { + if (request.id !== undefined) { + this.sendToSession( + session, + buildJsonRpcResult(request.id ?? null, releaseResultFor(method)), + ); + } + return; + } + + const rewritten = cloneWithRewrittenFirstParam(request, upstreamToken); + if (request.id === undefined) { + this.sendUpstream(rewritten); + return; + } + + const upstreamId = `broker:${this.requestCounter.toString(36)}:${session.id}`; + this.requestCounter += 1; + this.pending.set(upstreamId, { + sessionId: session.id, + clientId: request.id ?? null, + method, }); this.sendUpstream({ ...rewritten, id: upstreamId }); } @@ -569,21 +645,7 @@ class ChainBroker { return null; } - const upstreamToken = this.getUpstreamToken(firstParam); - if (upstreamToken === null) { - return null; - } - - return cloneWithRewrittenFirstParam(request, upstreamToken); - } - - private getUpstreamToken(localToken: string): string | null { - for (const [upstreamToken, owned] of this.upstreamToOwned.entries()) { - if (owned.localToken === localToken) { - return upstreamToken; - } - } - return null; + return cloneWithRewrittenFirstParam(request, owned.upstreamToken); } private handleUpstreamMessage(message: unknown): void { @@ -641,9 +703,16 @@ class ChainBroker { const event = result.event; const rawSub = parsed.params?.subscription; const token = typeof rawSub === "string" ? rawSub : "?"; - // Find which session owns this token - const owned = this.upstreamToOwned.get(token); - const sessionTag = owned ? owned.sessionId : "unknown"; + const ownedLocals = this.upstreamToOwned.get(token); + const sessionTag = + ownedLocals !== undefined && ownedLocals.size > 0 + ? [...ownedLocals] + .map( + (localToken) => + this.localToOwned.get(localToken)?.sessionId ?? "?", + ) + .join(",") + : "unknown"; if (event === "newBlock") { brokerLog( `← raw newBlock [${sessionTag}] hash=${String(result.blockHash).slice(0, 18)}… parent=${String(result.parentBlockHash).slice(0, 18)}… token=${token.slice(0, 12)}…`, @@ -716,6 +785,7 @@ class ChainBroker { buildJsonRpcResult(pendingLocal.requestId, pendingLocal.localToken), ); } + this.flushEarlySubscriptions(response.result); return; } @@ -735,10 +805,16 @@ class ChainBroker { const owned: OwnedToken = { sessionId: pending.sessionId, localToken, + upstreamToken: response.result, releaseMethod, }; this.localToOwned.set(localToken, owned); - this.upstreamToOwned.set(response.result, owned); + let localTokens = this.upstreamToOwned.get(response.result); + if (localTokens === undefined) { + localTokens = new Set(); + this.upstreamToOwned.set(response.result, localTokens); + } + localTokens.add(localToken); session.ownedTokens.add(localToken); brokerLog( `Token mapped: ${localToken} ↔ ${response.result} (${pending.method})`, @@ -754,6 +830,9 @@ class ChainBroker { rewritten.result = result; } this.sendToSession(session, rewritten); + if (releaseMethod !== undefined && typeof response.result === "string") { + this.flushEarlySubscriptions(response.result); + } } private handleUpstreamSubscription(message: SubscriptionMessage): void { @@ -818,41 +897,93 @@ class ChainBroker { return; } - const owned = this.upstreamToOwned.get(upstreamToken); - if (!owned) { + const ownedLocals = this.upstreamToOwned.get(upstreamToken); + if (ownedLocals === undefined || ownedLocals.size === 0) { + if (this.hasPendingSubscriptionRequest()) { + this.bufferEarlySubscription(upstreamToken, message); + return; + } brokerLog(`← upstream subscription for unknown token: ${upstreamToken}`); return; } - const session = this.sessions.get(owned.sessionId); - if (session?.connected !== true) { - brokerLog( - `← upstream subscription for disconnected session: ${owned.sessionId}`, - ); - return; - } - const eventResult = message.params?.result; const eventType = isJsonRpcObject(eventResult) ? typeof eventResult.event === "string" ? eventResult.event : "unknown" : "?"; - brokerLog( - `← subscription [${owned.sessionId}] event=${eventType} method=${String(message.method)}`, - ); + const localTokens = [...ownedLocals]; + for (const localToken of localTokens) { + const owned = this.localToOwned.get(localToken); + if (owned === undefined) { + continue; + } - this.sendToSession(session, { - ...message, - params: { - ...message.params, - subscription: owned.localToken, - }, - }); + const session = this.sessions.get(owned.sessionId); + if (session?.connected !== true) { + brokerLog( + `← upstream subscription for disconnected session: ${owned.sessionId}`, + ); + continue; + } + + brokerLog( + `← subscription [${owned.sessionId}] event=${eventType} method=${String(message.method)}`, + ); + + this.sendToSession(session, { + ...message, + params: { + ...message.params, + subscription: owned.localToken, + }, + }); + } if (isJsonRpcObject(eventResult) && eventResult.event === "stop") { - brokerLog(`Token stopped by upstream: ${owned.localToken}`); - this.releaseOwnedToken(owned.localToken, false); + brokerLog(`Token stopped by upstream: ${upstreamToken}`); + for (const localToken of localTokens) { + this.releaseOwnedToken(localToken, false); + } + } + } + + private hasPendingSubscriptionRequest(): boolean { + return [...this.pending.values()].some( + ({ method }) => + method === "chainHead_v1_follow" || TOKEN_METHODS.has(method), + ); + } + + private bufferEarlySubscription( + upstreamToken: string, + message: SubscriptionMessage, + ): void { + let events = this.earlySubscriptions.get(upstreamToken); + if (events === undefined) { + if (this.earlySubscriptions.size >= MAX_EARLY_SUBSCRIPTION_TOKENS) { + const oldestToken = this.earlySubscriptions.keys().next().value; + if (oldestToken !== undefined) { + this.earlySubscriptions.delete(oldestToken); + } + } + events = []; + this.earlySubscriptions.set(upstreamToken, events); + } + if (events.length < MAX_EARLY_SUBSCRIPTION_EVENTS_PER_TOKEN) { + events.push(message); + } + } + + private flushEarlySubscriptions(upstreamToken: string): void { + const events = this.earlySubscriptions.get(upstreamToken); + if (events === undefined) { + return; + } + this.earlySubscriptions.delete(upstreamToken); + for (const event of events) { + this.handleUpstreamSubscription(event); } } @@ -889,46 +1020,49 @@ class ChainBroker { } } - private releaseOwnedToken(localToken: string, notifyUpstream: boolean): void { + private releaseOwnedToken( + localToken: string, + notifyUpstream: boolean, + ): { lastOwner: boolean } | null { const owned = this.localToOwned.get(localToken); if (!owned) { - return; + return null; } this.localToOwned.delete(localToken); const session = this.sessions.get(owned.sessionId); session?.ownedTokens.delete(localToken); - let upstreamTokenToDelete: string | null = null; - for (const [upstreamToken, candidate] of this.upstreamToOwned.entries()) { - if (candidate.localToken === localToken) { - upstreamTokenToDelete = upstreamToken; - break; - } + const localTokens = this.upstreamToOwned.get(owned.upstreamToken); + if (localTokens?.delete(localToken) !== true) { + return null; } - if (upstreamTokenToDelete === null) { - return; + + if (localTokens.size > 0) { + return { lastOwner: false }; } - this.upstreamToOwned.delete(upstreamTokenToDelete); + this.upstreamToOwned.delete(owned.upstreamToken); if (!notifyUpstream) { - return; + return { lastOwner: true }; } this.sendUpstream({ jsonrpc: "2.0", id: `broker-release:${this.requestCounter.toString(36)}`, method: owned.releaseMethod, - params: [upstreamTokenToDelete], + params: [owned.upstreamToken], }); this.requestCounter += 1; + return { lastOwner: true }; } private disconnectUpstream(): void { this.pending.clear(); this.localToOwned.clear(); this.upstreamToOwned.clear(); + this.earlySubscriptions.clear(); this.localFollowTokens.clear(); this.sharedFollows.clear(); this.upstreamFollowTokens.clear(); diff --git a/packages/protocol/tests/broker.test.ts b/packages/protocol/tests/broker.test.ts index c3b5a9e..42b7038 100644 --- a/packages/protocol/tests/broker.test.ts +++ b/packages/protocol/tests/broker.test.ts @@ -201,6 +201,282 @@ describe("createChainBrokerManager", () => { expect(harness.disconnect).toHaveBeenCalledTimes(1); }); + it("releases transactionWatch subscriptions on disconnect", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const connection = manager.connectRemote("asset-hub", "conn-a", () => {}); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x0102"], + }), + ); + + const upstreamRequest = harness.sent[0] as { id: string; method: string }; + expect(upstreamRequest.method).toBe("transactionWatch_v1_submitAndWatch"); + harness.emit({ jsonrpc: "2.0", id: upstreamRequest.id, result: "up-tx" }); + + connection?.disconnect(); + + const release = harness.sent[1] as { method: string; params: string[] }; + expect(release.method).toBe("transactionWatch_v1_unwatch"); + expect(release.params[0]).toBe("up-tx"); + }); + + it("delivers transactionWatch events received before the subscribe response", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messages: string[] = []; + const connection = manager.connectRemote("asset-hub", "conn-a", (message) => + messages.push(message), + ); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x0102"], + }), + ); + + const upstreamRequest = harness.sent[0] as { id: string }; + harness.emit({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: "up-tx", + result: { event: "finalized", block: { hash: "0xabc" } }, + }, + }); + expect(messages).toEqual([]); + + harness.emit({ + jsonrpc: "2.0", + id: upstreamRequest.id, + result: "up-tx", + }); + + const response = JSON.parse(messages[0] ?? "{}") as { result: string }; + expect(JSON.parse(messages[1] ?? "{}")).toEqual({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: response.result, + result: { event: "finalized", block: { hash: "0xabc" } }, + }, + }); + }); + + it("fans out same-token statement notifications to every local owner", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messagesA: string[] = []; + const messagesB: string[] = []; + const connectionA = manager.connectRemote("asset-hub", "conn-a", (m) => + messagesA.push(m), + ); + const connectionB = manager.connectRemote("asset-hub", "conn-b", (m) => + messagesB.push(m), + ); + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[0] as { id: string }).id, + result: "up-stmt", + }); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[1] as { id: string }).id, + result: "up-stmt", + }); + + const localTokenA = (JSON.parse(messagesA[0] ?? "{}") as { result: string }) + .result; + const localTokenB = (JSON.parse(messagesB[0] ?? "{}") as { result: string }) + .result; + expect(localTokenA).not.toBe(localTokenB); + + harness.emit({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: "up-stmt", + result: { event: "newStatements", data: { statements: [] } }, + }, + }); + + expect(messagesA[1]).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenA, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + expect(messagesB[1]).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenB, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + + connectionA?.disconnect(); + expect( + harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === + "statement_unsubscribeStatement", + ), + ).toHaveLength(0); + + connectionB?.disconnect(); + const releases = harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === "statement_unsubscribeStatement", + ); + expect(releases).toHaveLength(1); + expect((releases[0]?.params as unknown[])[0]).toBe("up-stmt"); + }); + + it("ref-counts same-token statement unsubscribe requests", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messagesA: string[] = []; + const messagesB: string[] = []; + const connectionA = manager.connectRemote("asset-hub", "conn-a", (m) => + messagesA.push(m), + ); + const connectionB = manager.connectRemote("asset-hub", "conn-b", (m) => + messagesB.push(m), + ); + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[0] as { id: string }).id, + result: "up-stmt", + }); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[1] as { id: string }).id, + result: "up-stmt", + }); + const localTokenA = (JSON.parse(messagesA[0] ?? "{}") as { result: string }) + .result; + const localTokenB = (JSON.parse(messagesB[0] ?? "{}") as { result: string }) + .result; + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 10, + method: "statement_unsubscribeStatement", + params: [localTokenA], + }), + ); + expect(JSON.parse(messagesA.at(-1) ?? "{}")).toEqual({ + jsonrpc: "2.0", + id: 10, + result: true, + }); + expect( + harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === + "statement_unsubscribeStatement", + ), + ).toHaveLength(0); + + harness.emit({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: "up-stmt", + result: { event: "newStatements", data: { statements: [] } }, + }, + }); + expect(messagesA).toHaveLength(2); + expect(messagesB.at(-1)).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenB, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 11, + method: "statement_unsubscribeStatement", + params: [localTokenB], + }), + ); + const release = harness.sent.at(-1) as { + id: string; + method: string; + params: string[]; + }; + expect(release.method).toBe("statement_unsubscribeStatement"); + expect(release.params[0]).toBe("up-stmt"); + + harness.emit({ + jsonrpc: "2.0", + id: release.id, + result: true, + } as JsonRpcMessage); + expect(JSON.parse(messagesB.at(-1) ?? "{}")).toEqual({ + jsonrpc: "2.0", + id: 11, + result: true, + }); + }); + it("reuses the warm upstream when a new session attaches after every previous one disconnected", () => { const harness = createProviderHarness(); const manager = createChainBrokerManager(() => harness.provider);