diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index 4a3a9cea..12e609bb 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -114,7 +114,17 @@ export interface MeshSession { readonly coordinatorFrames: AsyncIterable; connect: (address: string, localDomains: readonly string[]) => Promise; sendPing: () => Promise; - /** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */ + /** + * Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives. + * + * The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means. + * + * Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as. + * + * Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever. + * + * It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. + */ sendPingMeasureRtt: (timeoutMs?: number) => Promise; /** Attaches this token to every `manage-request` sent from now on. */ setToken: (token: CapabilityToken) => void; diff --git a/ts/packages/core/src/domain/ping-round-trips.ts b/ts/packages/core/src/domain/ping-round-trips.ts index fd68c0db..ba2635c5 100644 --- a/ts/packages/core/src/domain/ping-round-trips.ts +++ b/ts/packages/core/src/domain/ping-round-trips.ts @@ -1,4 +1,12 @@ -// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181) -- ping-frame/pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly. +// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181). ping-frame and pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly. + +/** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */ +export class PingTimeoutError extends Error { + constructor() { + super("timed out waiting for pong"); + this.name = "PingTimeoutError"; + } +} interface PendingPingRoundTrip { sentAt: number; @@ -58,7 +66,7 @@ export function createPingRoundTrips(): PingRoundTrips { new Promise((_resolve, reject) => { setTimeout(() => { if (cancel(sentAt)) { - reject(new Error("timed out waiting for pong")); + reject(new PingTimeoutError()); } }, timeoutMs); }), diff --git a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts index 7fd5c364..00979fbb 100644 --- a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts +++ b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Clock } from "../src/ports/clock.js"; import { createMeshSession } from "../src/domain/mesh-session.js"; +import { PingTimeoutError } from "../src/domain/ping-round-trips.js"; import { fakeTransport, testIdentity, @@ -82,6 +83,7 @@ describe("sendPingMeasureRtt", () => { const pending = session.sendPingMeasureRtt(); connection.fail(new Error("dropped")); await expect(pending).rejects.toThrow("disconnected before a pong arrived"); + await expect(pending).rejects.not.toBeInstanceOf(PingTimeoutError); }); it("times out rather than hanging forever when no pong ever arrives", async () => { @@ -97,7 +99,7 @@ describe("sendPingMeasureRtt", () => { .catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(PING_ROUND_TRIP_TIMEOUT_MS); const outcome = await pending; - expect(outcome).toBeInstanceOf(Error); + expect(outcome).toBeInstanceOf(PingTimeoutError); expect((outcome as Error).message).toBe("timed out waiting for pong"); await session.close(); } finally { diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index 7936bc03..ea964652 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,7 +8,7 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning. +- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning. - **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button. ## How it maps onto core's ports diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 3835163e..bb5bfb55 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -11,10 +11,7 @@ import { Title, } from "@mantine/core"; import { createMeshSession } from "wire-mesh-core/domain/mesh-session"; -import type { - MeshSession, - ReconnectPolicy, -} from "wire-mesh-core/domain/mesh-session"; +import type { MeshSession } from "wire-mesh-core/domain/mesh-session"; import { createGossipExpansion } from "wire-mesh-core/domain/gossip-expansion"; import type { GossipExpansion, @@ -26,6 +23,7 @@ import type { Connection } from "wire-mesh-core/ports/transport"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; +import { reconnectPolicy } from "./reconnect-policy.js"; import { createDialTransport } from "./adapters/dial-transport.js"; import type { CertificateMemory } from "./certificate-memory.js"; import { DEFAULT_ICE_SERVERS } from "./ice-servers.js"; @@ -72,20 +70,6 @@ const DEFAULT_ADDRESS = "ws://localhost:8787"; const AVAILABLE_DOMAINS = ["core/management", "core/exec", "core/data"]; const DEFAULT_DOMAINS = ["core/management", "core/data"]; -// Exponential backoff, capped at 30s, giving up after 5 attempts -- reasonable defaults for a browser console reconnecting to a relay that may just be restarting, without retrying forever against one that is genuinely gone. -const RECONNECT_MAX_ATTEMPTS = 5; -const RECONNECT_BASE_DELAY_MS = 1000; -const RECONNECT_MAX_DELAY_MS = 30_000; -const RECONNECT_BACKOFF_BASE = 2; -const reconnectPolicy: ReconnectPolicy = { - maxAttempts: RECONNECT_MAX_ATTEMPTS, - delayMs: (attempt) => - Math.min( - RECONNECT_BASE_DELAY_MS * RECONNECT_BACKOFF_BASE ** (attempt - 1), - RECONNECT_MAX_DELAY_MS, - ), -}; - /** A direct connection attempt that did not open, kept on screen until dismissed so a refusal or a failure to negotiate is never silent. The conversation itself carries on through the hub. */ interface ConnectionFailure { key: string; @@ -121,7 +105,7 @@ export function App({ const [connections, setConnections] = useState([]); const [discovered, setDiscovered] = useState([]); const [failures, setFailures] = useState([]); - const trust = useCertificateTrust(certificateMemory); + const trust = useCertificateTrust(certificateMemory, clock); const roomMessaging = useRoomMessaging(identity, clock, messageStore); const [selectedPath, setSelectedPath] = useState(); // The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists. @@ -449,6 +433,9 @@ export function App({ key={entry.address} address={entry.address} session={entry.session} + clock={clock} + reconnectPolicy={reconnectPolicy} + certificateChanges={trust.changes} onClose={() => { handleClose(entry); }} diff --git a/ts/packages/web-console/src/activity.ts b/ts/packages/web-console/src/activity.ts new file mode 100644 index 00000000..71ab5968 --- /dev/null +++ b/ts/packages/web-console/src/activity.ts @@ -0,0 +1,220 @@ +// The console's human reading of a session: what a person wants to know happened ("connected", "pairing established", "peer seen", "certificate changed"), derived from the session's state changes and the frames that crossed it. The raw frames stay available beside it for debugging. Pure, so the wording and the change detection can be tested without a session. + +import type { SessionEvent } from "wire-mesh-core/domain/mesh-session"; +import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; + +export type ActivityKind = + "connection" | "peer" | "pairing" | "relay" | "certificate"; + +export interface ActivityEntry { + key: string; + /** When the console observed it, from the injected clock. */ + at: number; + kind: ActivityKind; + /** The sentence, which ends where the peer's name goes when `peer` is set. */ + summary: string; + /** Hex device-id of the peer the entry is about, named by the console's naming convention when shown. */ + peer: string | undefined; +} + +/** What has already been turned into entries, so the next event yields only what is new. */ +export interface ActivityTracker { + /** How many frame-log entries have been read. The log only ever grows. */ + frames: number; + /** The connection stage last reported: the status, with the attempt number while reconnecting, so each failed retry is a change and a repeated event is not. */ + stage: string | undefined; + handshake: string | undefined; + /** Sequence for entry keys. */ + issued: number; + /** Hex device-ids already reported as seen. A directory is gossiped again and again with the same peers, so each is reported the first time only; otherwise a busy directory would push every other entry out of the capped activity view. */ + seen: ReadonlySet; +} + +export const initialTracker: ActivityTracker = { + frames: 0, + stage: undefined, + handshake: undefined, + issued: 0, + seen: new Set(), +}; + +type Described = Pick; + +function hex(device: DeviceId): string { + return deviceIdToHex(device); +} + +/** What one frame means to a person, or nothing for plumbing (pings, handshakes and the like have their own views or are only for the raw log). */ +export function describeFrame( + direction: "sent" | "received", + frame: Readonly, +): Described[] { + if (frame.type === "gossip") { + return direction === "received" + ? frame.peers.map((advert) => ({ + kind: "peer", + summary: "Peer seen:", + peer: hex(advert.device), + })) + : [ + { + kind: "peer", + summary: "Advertised this console to the node", + peer: undefined, + }, + ]; + } + if (frame.type === "relay-connect") { + return [ + { + kind: "pairing", + summary: "Asked the node to pair this console with", + peer: hex(frame["target-device"]), + }, + ]; + } + if (frame.type === "relay-inbound") { + return [ + { + kind: "pairing", + summary: "Pairing established with", + peer: hex(frame["source-device"]), + }, + ]; + } + if (frame.type === "relay-data") { + const other = + direction === "sent" ? frame["to-device"] : frame["from-device"]; + return [ + { + kind: "relay", + summary: + direction === "sent" + ? "Relayed a message to" + : "Relayed message received from", + peer: other === undefined ? undefined : hex(other), + }, + ]; + } + if (frame.type === "close") { + return [ + { + kind: "connection", + summary: "The node closed the connection", + peer: undefined, + }, + ]; + } + return []; +} + +function connectionStage(state: Readonly): string { + return state.status === "reconnecting" + ? `reconnecting:${String(state.attempt)}` + : state.status; +} + +function describeState( + previous: Readonly, + state: Readonly, + handshake: string | undefined, +): Described[] { + const entries: Described[] = []; + const connection = (summary: string): void => { + entries.push({ kind: "connection", summary, peer: undefined }); + }; + if (connectionStage(state) !== previous.stage) { + switch (state.status) { + case "connecting": + connection(`Connecting to ${state.address}`); + break; + case "connected": + connection(`Connected to ${state.address}`); + break; + case "reconnecting": + connection( + `${previous.stage?.startsWith("reconnecting") === true ? "Retry failed" : "Connection lost"} (${state.reason}); retrying, attempt ${String(state.attempt)}`, + ); + break; + case "closed": + connection(`Closed (${state.reason})`); + break; + case "idle": + break; + } + } + if (state.status === "connected" && handshake !== previous.handshake) { + if (state.handshake.status === "negotiated") { + connection( + `Handshake negotiated: ${state.handshake.sharedDomains.join(", ")}`, + ); + } else if (state.handshake.status === "unanswered") { + connection("The node does not answer handshakes (a relay-only node)"); + } else if (state.handshake.status === "rejected") { + connection(`Handshake rejected (${state.handshake.reason})`); + } + } + return entries; +} + +/** The handshake stage of a state, or undefined when not connected. */ +function handshakeStage( + state: Readonly, +): string | undefined { + return state.status === "connected" ? state.handshake.status : undefined; +} + +/** The entries an event adds beyond what `tracker` has already seen, and the tracker to use for the next event. */ +export function advanceActivity( + tracker: Readonly, + event: Readonly, + at: number, +): { tracker: ActivityTracker; entries: ActivityEntry[] } { + const handshake = handshakeStage(event.state); + const seen = new Set(tracker.seen); + const described: Described[] = [ + ...describeState(tracker, event.state, handshake), + ...event.frameLog + .slice(tracker.frames) + .flatMap((entry) => describeFrame(entry.direction, entry.frame)) + .filter((entry) => { + if (entry.kind !== "peer" || entry.peer === undefined) { + return true; + } + if (seen.has(entry.peer)) { + return false; + } + seen.add(entry.peer); + return true; + }), + ]; + const entries = described.map((entry, index): ActivityEntry => ({ + ...entry, + key: `activity-${String(tracker.issued + index)}`, + at, + })); + return { + tracker: { + frames: event.frameLog.length, + stage: connectionStage(event.state), + handshake, + issued: tracker.issued + entries.length, + seen, + }, + entries, + }; +} + +/** The activity entries for certificate changes announced by a node, for merging into that node's connection activity. */ +export function certificateActivity( + changes: readonly { key: string; at: number; node: string }[], +): ActivityEntry[] { + return changes.map((change) => ({ + key: `certificate-${change.key}`, + at: change.at, + kind: "certificate", + summary: `Certificate changed: ${change.node} announced certificates unlike the remembered ones`, + peer: undefined, + })); +} diff --git a/ts/packages/web-console/src/components/ActivityLog.tsx b/ts/packages/web-console/src/components/ActivityLog.tsx new file mode 100644 index 00000000..d931e6c1 --- /dev/null +++ b/ts/packages/web-console/src/components/ActivityLog.tsx @@ -0,0 +1,95 @@ +// The connection's activity as sentences with their times, and the raw frame log behind a toggle for debugging. + +import { useState } from "react"; +import { Switch, Table, Text } from "@mantine/core"; +import type { FrameLogEntry } from "wire-mesh-core/domain/mesh-session"; +import type { ActivityEntry } from "../activity.js"; +import { PeerName } from "./PeerName.js"; + +function describeRawFrame(frame: unknown): string { + return JSON.stringify(frame, (_key: string, value: unknown): unknown => + value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, + ); +} + +export interface ActivityLogProps { + activity: readonly ActivityEntry[]; + frameLog: readonly FrameLogEntry[]; +} + +export function ActivityLog({ + activity, + frameLog, +}: Readonly): React.JSX.Element { + const [raw, setRaw] = useState(false); + return ( + <> + { + setRaw(event.currentTarget.checked); + }} + /> + {raw ? ( + + + + + direction + frame + + + + {frameLog.map((entry, index) => ( + + {entry.direction} + {describeRawFrame(entry.frame)} + + ))} + +
+
+ ) : ( + <> + + Activity + + {activity.length === 0 ? ( + + Nothing has happened yet. + + ) : ( + + + + {[...activity].reverse().map((entry) => ( + + + {new Date(entry.at).toLocaleTimeString()} + + + {entry.summary} + {entry.peer !== undefined && ( + <> + {" "} + + + )} + + + ))} + +
+
+ )} + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx index 5ebac0bc..fcd0f9f4 100644 --- a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx +++ b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx @@ -89,7 +89,8 @@ export function CertificateTrustPanel({ changes, onDismissChange, }: Readonly): React.JSX.Element | null { - if (prompts.length === 0 && changes.length === 0) { + const undismissed = changes.filter((change) => !change.dismissed); + if (prompts.length === 0 && undismissed.length === 0) { return null; } return ( @@ -97,7 +98,7 @@ export function CertificateTrustPanel({ {prompts.map((prompt) => ( ))} - {changes.map((change) => ( + {undismissed.map((change) => ( ; + /** When the connection's status last changed, which is when a reconnect's backoff started. */ + statusSince: number | undefined; + now: number; + policy: Readonly; + health: Readonly; +} + +function ReconnectStatus({ + state, + statusSince, + now, + policy, +}: Readonly< + Pick & { + state: Extract; + } +>): React.JSX.Element { + const retryAt = + statusSince === undefined + ? undefined + : statusSince + policy.delayMs(state.attempt); + const due = retryAt === undefined ? 0 : retryAt - now; + return ( + + {due > 0 ? `Retrying in ${formatRemaining(due)}` : "Retrying now"}{" "} + (attempt {state.attempt} of {policy.maxAttempts}). Last failure:{" "} + {state.reason} + + ); +} + +export function ConnectionHealth({ + state, + statusSince, + now, + policy, + health, +}: Readonly): React.JSX.Element | null { + if (state.status === "reconnecting") { + return ( + + ); + } + if (state.status !== "connected") { + return null; + } + return ( + + + {health.unresponsive && ( + + This node does not answer pings, so round trips are not measured + automatically. + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index 0d88cf4c..7bba7cb8 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,18 +1,38 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. -import { Alert, Button, Card, Group, Table, Text } from "@mantine/core"; +import { + Alert, + Button, + Card, + Group, + Table, + Text, + Tooltip, +} from "@mantine/core"; import type { MeshSession, + ReconnectPolicy, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; +import type { Clock } from "wire-mesh-core/ports/clock"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; import { selfNameExtension } from "../peer-names.js"; +import { useConnectionHealth } from "../hooks/use-connection-health.js"; +import { useNow } from "../hooks/use-now.js"; +import { certificateActivity } from "../activity.js"; +import { formatAgo } from "../format-duration.js"; +import { presentedCertificates } from "../certificate-trust.js"; +import { toDialAddress } from "../dial-address.js"; +import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js"; +import { ActivityLog } from "./ActivityLog.js"; +import { ConnectionHealth } from "./ConnectionHealth.js"; import { PeerLabel } from "./PeerLabel.js"; const HEX_RADIX = 16; +const MS_PER_SECOND = 1000; export function deviceHex(device: Uint8Array): string { let hex = ""; @@ -56,26 +76,35 @@ function describeStatus(event: Readonly): string { return ""; } -function describeFrame(frame: unknown): string { - return JSON.stringify(frame, (_key: string, value: unknown): unknown => - value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, - ); -} - export interface ConnectionPanelProps { address: string; session: Readonly; + clock: Readonly; + /** The backoff the session redials with, so the panel can say when the next attempt is due. */ + reconnectPolicy: Readonly; + /** Every certificate change seen for any node; the panel shows those for its own node in its activity. */ + certificateChanges: readonly NodeCertificateChange[]; onClose: () => void; onMessagePeer: (device: DeviceId) => void; } +/** How often elapsed times and the reconnect countdown refresh. */ +const CLOCK_TICK_MS = 1000; + export function ConnectionPanel({ address, session, + clock, + reconnectPolicy, + certificateChanges, onClose, onMessagePeer, }: Readonly): React.JSX.Element { - const event = useMeshSessionEvents(session); + const { + event, + activity: sessionActivity, + statusSince, + } = useMeshSessionEvents(session, clock); const { observeDirectory, selfName } = usePeerNames(); const connected = event?.state.status === "connected"; useEffect(() => { @@ -110,19 +139,31 @@ export function ConnectionPanel({ const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; + const health = useConnectionHealth(session, connected); + const reconnecting = event?.state.status === "reconnecting"; + const now = useNow( + clock, + reconnecting || directory.length > 0, + CLOCK_TICK_MS, + ); + const node = presentedCertificates(toDialAddress(address))?.node; + const activity = useMemo( + () => + [ + ...sessionActivity, + ...certificateActivity( + certificateChanges.filter((change) => change.node === node), + ), + ].sort((a, b) => a.at - b.at), + [sessionActivity, certificateChanges, node], + ); return ( {address} -