diff --git a/docs/architecture.md b/docs/architecture.md
index 2d34f20..e212590 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -141,6 +141,12 @@ for transaction-detail consumers. Captured workspace operations also check the
session token, so callbacks cannot borrow a later reopening of the same ID.
App supplies transient network admission separately from session validity:
cached transaction reads and saved spending links work without live admission.
+Graph lookup reports completion through its existing `submit` intention rather
+than requiring callers to infer success from loaded evidence. Workspace opens
+Graph after completion, capturing the originating workbench for a contextual
+return. The global linked-lookup banner clears only the reference that completed;
+rejected, unavailable, cancelled and failed lookups leave it pending. Operation
+admission and background preemption remain with `useWorkspaceOperation`.
Graph ancestry can reveal only saved parents while offline, without trying
missing transactions. Inspector navigation checks saved parents and exact spend
links across all saved transactions, independently of canvas membership; live
diff --git a/docs/user-guide.md b/docs/user-guide.md
index ab3b2d4..fdc0a6b 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -173,6 +173,12 @@ immediately, even offline. The **Previous** selector beside the input loads one
or two levels of earlier transactions along with the lookup; it defaults to off
and both levels share a 500-transaction budget.
+Links ending in `#?s=txid`, `#?s=txid:index` or `#?s=address` show a linked-lookup
+banner and clear the reference from the address bar. Open or unlock a workspace,
+then click the reference to add it and open Graph. The active workspace chooses
+the network. If the lookup cannot complete, the banner stays so you can retry in
+that workspace or switch to another. Use its dismiss button to discard the link.
+
With a transaction selected, **Load previous txs** adds one earlier level. For an
output, **Open creating tx** loads only the transaction that created it, and
**Find spending txs** looks for transactions that spent it (or any output of a
diff --git a/src/App/App.tsx b/src/App/App.tsx
index 787bf9b..ac7843a 100644
--- a/src/App/App.tsx
+++ b/src/App/App.tsx
@@ -4,6 +4,7 @@ import { TransactionFetchProvider } from './Workspace/TransactionFetchProvider';
import { ExamplesDialog } from './Examples/ExamplesDialog';
import { BookOpen, FolderOpen, Info, Library, Plus } from 'lucide-react';
import { ToastNotification } from './Controls/ToastNotification';
+import { DeepLinkBanner } from './DeepLinkBanner';
import { Modal } from './Dialogs';
import { CreateWorkspaceDialog } from './Workspace/Dialogs/CreateWorkspaceDialog';
import { ImportWorkspaceDialog } from './Workspace/Dialogs/ImportWorkspaceDialog';
@@ -231,6 +232,7 @@ export default function App() {
)}
+
;
+ workspace: WorkspaceController;
+}
+
+function compactReference(ref: string) {
+ return ref.length > 24 ? `${ref.slice(0, 10)}…${ref.slice(-8)}` : ref;
+}
+
+function isAddressForNetwork(ref: string, network: Network) {
+ try {
+ addressToScriptHash(ref, network);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Shows a lookup reference carried by a #?s= URL fragment until it is added to
+ * a graph or dismissed. The fragment itself is consumed on arrival; this
+ * banner is the only remaining holder of the reference.
+ */
+export function DeepLinkBanner({ app, workspace }: DeepLinkBannerProps) {
+ const ref = app.deepLinkRef;
+ if (!ref) return null;
+ const addToGraph = async () => {
+ if (!app.activeWorkspace) {
+ app.setNotice(
+ 'Open or create a workspace first, then add the linked reference from the banner.',
+ );
+ return;
+ }
+ const valid =
+ /^[0-9a-f]{64}(:\d+)?$/i.test(ref) || isAddressForNetwork(ref, app.activeWorkspace.network);
+ if (!valid) {
+ app.setNotice('The link does not contain a valid transaction ID, output, or address.');
+ return;
+ }
+ if (await workspace.graph.lookup.submit(ref))
+ app.setDeepLinkRef((pending) => (pending === ref ? undefined : pending));
+ };
+ return (
+
+ Linked lookup
+ void addToGraph()}
+ >
+ {compactReference(ref)}
+
+ app.setDeepLinkRef(undefined)}
+ >
+
+
+
+ );
+}
diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx b/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx
index 3233b2b..f8625ba 100644
--- a/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx
+++ b/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx
@@ -14,7 +14,7 @@ export interface GraphLookupFormProps {
onQueryError: (message: string) => void;
/** Resolves text that is already present in the workspace, so offline lookups stay enabled. */
resolveLoaded: (text: string) => string | undefined;
- onSubmit: (text: string) => void | Promise;
+ onSubmit: (text: string) => Promise;
}
/**
diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx
index abaa8f7..ca508ee 100644
--- a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx
+++ b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx
@@ -81,10 +81,12 @@ it.each([
);
});
expect(f.core.setOperation).not.toHaveBeenCalled();
+ let completed = false;
await act(async () => {
- await f.result.current.lookup.submit(text);
+ completed = await f.result.current.lookup.submit(text);
await background;
});
+ expect(completed).toBe(true);
expect(backgroundSignal.aborted).toBe(true);
expect(f.reveal).toHaveBeenCalledWith(expected);
expect(f.result.current.state.resetToken).toBe(1);
@@ -111,7 +113,9 @@ it('keeps a cached lookup from changing selection during another foreground oper
});
});
});
- await act(() => f.result.current.lookup.submit(TX_FUNDING));
+ await act(async () => {
+ expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(false);
+ });
expect(f.reveal).not.toHaveBeenCalled();
expect(f.observe).not.toHaveBeenCalled();
expect(f.result.current.state.resetToken).toBe(0);
@@ -121,7 +125,9 @@ it('keeps a cached lookup from changing selection during another foreground oper
it('still opens loaded evidence while live lookups are unavailable', async () => {
const f = fixture(true, false);
- await act(() => f.result.current.lookup.submit(TX_FUNDING));
+ await act(async () => {
+ expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(true);
+ });
expect(f.reveal).toHaveBeenCalledWith(`tx:${TX_FUNDING}`);
expect(f.read).not.toHaveBeenCalled();
});
@@ -140,13 +146,13 @@ it('drops a lookup if selection changes while automatic work is yielding', async
{ background: true },
);
});
- let lookup!: Promise;
+ let lookup!: Promise;
await act(async () => {
lookup = f.result.current.lookup.submit(TX_FUNDING);
f.selection.generation.current++;
release();
await background;
- await lookup;
+ expect(await lookup).toBe(false);
});
expect(f.reveal).not.toHaveBeenCalled();
expect(f.observe).not.toHaveBeenCalled();
diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts
index 70e8654..9a84700 100644
--- a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts
+++ b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts
@@ -44,11 +44,13 @@ export function useGraphLookup({
const { transactions: recordTransactions } = transactions.observe;
const { run } = operation;
- async function submit(text: string) {
- if (!activeWorkspace || !text.trim()) return;
+ /** True only after an admitted lookup completes; failures use the operation's feedback. */
+ async function submit(text: string): Promise {
+ if (!activeWorkspace || !text.trim()) return false;
text = text.trim();
if (/^(bc1|tb1)/i.test(text)) text = text.toLowerCase();
const submittedGeneration = selectionGeneration.current;
+ let completed = false;
await run(async (signal) => {
if (
activeWorkspaceRef.current?.id !== activeWorkspace.id ||
@@ -56,22 +58,26 @@ export function useGraphLookup({
)
return;
const existing = state.resolveLoaded(text);
+ if (!canLoadChainData && !existing)
+ throw new Error(
+ 'Cannot add this reference: it is not loaded in this workspace and live lookup is unavailable.',
+ );
if (existing && !existing.startsWith('addr:')) {
recordTransactions(activeWorkspace.id, [], { promotionIds: [existing.split(':')[1]] });
reveal(existing);
if (!canLoadChainData || !prefetchDepth) {
state.clear();
+ completed = true;
return;
}
}
if (!/^[0-9a-f]{64}(:\d+)?$/i.test(text)) {
- if (!canLoadChainData && !existing) return;
reveal(addressReference(text));
state.clear();
if (canLoadChainData) addressEvidence.startAddressHistoryLoad(text);
+ completed = true;
return;
}
- if (!canLoadChainData) return;
const generation = selectionGeneration.current;
const [id, index] = text.split(':');
setOperation('Loading transaction…');
@@ -118,7 +124,9 @@ export function useGraphLookup({
setNotice(ancestryNotice(result));
}
state.clear();
+ completed = true;
});
+ return completed;
}
return { submit };
diff --git a/src/App/Workspace/useWorkspace.tsx b/src/App/Workspace/useWorkspace.tsx
index 9513532..6540be3 100644
--- a/src/App/Workspace/useWorkspace.tsx
+++ b/src/App/Workspace/useWorkspace.tsx
@@ -624,6 +624,13 @@ export function useWorkspace(app: ReturnType) {
return (next: WorkbenchMode, options?: WorkbenchSwitchOptions) =>
changeWorkbench(next, options, capturedOrigin);
}
+ async function submitGraphLookup(text: string) {
+ const showGraph = workbenchActionSwitch(workbench);
+ const completed = await graphLookup.submit(text);
+ if (completed)
+ showGraph('graph', { interaction: workbench === 'graph' ? 'switch' : 'handoff' });
+ return completed;
+ }
// The factory only creates event handlers; refs are read when an action runs, not during render.
// oxlint-disable-next-line react/refs
// What Graph publishes to the other workbenches, assembled once.
@@ -714,7 +721,7 @@ export function useWorkspace(app: ReturnType) {
actions: graphActions,
address: graphAddress,
navigation: graphExpansion,
- lookup: { ...lookup, submit: graphLookup.submit },
+ lookup: { ...lookup, submit: submitGraphLookup },
flowInputs,
scanTargets: connectionScanTargets,
},
diff --git a/src/App/component-styles.ts b/src/App/component-styles.ts
index 6b53fb4..3ea8263 100644
--- a/src/App/component-styles.ts
+++ b/src/App/component-styles.ts
@@ -24,3 +24,4 @@ import './Workspace/Workbenches/Wallet/wallet-workbench.css';
import './Controls/multi-select-filter.css';
import './Workspace/Workbenches/workbenches.css';
import './Workspace/Workbenches/Graph/EntitiesPanel/entity-browser.css';
+import './deeplink-banner.css';
diff --git a/src/App/deepLink.test.ts b/src/App/deepLink.test.ts
new file mode 100644
index 0000000..5a1309b
--- /dev/null
+++ b/src/App/deepLink.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { parseLookupFragment } from './deepLink';
+
+const txid = 'a'.repeat(64);
+// Public BIP173 test vector.
+const address = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4';
+
+describe('parseLookupFragment', () => {
+ it('reads a transaction id from #?s=', () => {
+ expect(parseLookupFragment(`#?s=${txid}`)).toBe(txid);
+ });
+
+ it('reads an outpoint from #?s=', () => {
+ expect(parseLookupFragment(`#?s=${txid}:0`)).toBe(`${txid}:0`);
+ });
+
+ it('reads an address from #?s=', () => {
+ expect(parseLookupFragment(`#?s=${address}`)).toBe(address);
+ });
+
+ it('keeps other parameters and returns the first s value', () => {
+ expect(parseLookupFragment(`#?s=${txid}&x=1`)).toBe(txid);
+ expect(parseLookupFragment(`#?s=${txid}&s=${'b'.repeat(64)}`)).toBe(txid);
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(parseLookupFragment(`#?s=%20${txid}%20`)).toBe(txid);
+ });
+
+ it('ignores in-app anchors and non-lookup fragments', () => {
+ expect(parseLookupFragment('#main-workspace')).toBeUndefined();
+ expect(parseLookupFragment('#graph-workspace')).toBeUndefined();
+ expect(parseLookupFragment('#')).toBeUndefined();
+ expect(parseLookupFragment('')).toBeUndefined();
+ expect(parseLookupFragment('#?foo=bar')).toBeUndefined();
+ expect(parseLookupFragment('#?s=')).toBeUndefined();
+ expect(parseLookupFragment('#?s=%20%09')).toBeUndefined();
+ });
+});
diff --git a/src/App/deepLink.ts b/src/App/deepLink.ts
new file mode 100644
index 0000000..63fe388
--- /dev/null
+++ b/src/App/deepLink.ts
@@ -0,0 +1,9 @@
+/** Reads a lookup reference from a #?s= fragment, ignoring in-app hash anchors. */
+export function parseLookupFragment(hash: string): string | undefined {
+ if (!hash.startsWith('#?')) return undefined;
+ const params = new URLSearchParams(hash.slice(2));
+ const value = params.get('s');
+ if (value === null) return undefined;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
diff --git a/src/App/deeplink-banner.css b/src/App/deeplink-banner.css
new file mode 100644
index 0000000..5754fb4
--- /dev/null
+++ b/src/App/deeplink-banner.css
@@ -0,0 +1,34 @@
+/* Global banner for #?s= deep-link references; the toast stays the feedback surface. */
+.deep-link-banner {
+ position: fixed;
+ top: 68px;
+ right: 16px;
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ max-width: min(420px, calc(100vw - 32px));
+ padding: 6px 8px;
+ background: var(--color-raised);
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-xs);
+}
+.deep-link-banner-label {
+ color: var(--color-muted);
+ white-space: nowrap;
+}
+.deep-link-banner-ref {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-height: 28px;
+ padding: 4px 8px;
+ color: var(--color-accent);
+ background: transparent;
+ border-color: transparent;
+}
+.deep-link-banner-ref:hover {
+ background: var(--color-hover);
+ border-color: var(--color-faint);
+}
diff --git a/src/App/useAppState.ts b/src/App/useAppState.ts
index cae3edf..200210f 100644
--- a/src/App/useAppState.ts
+++ b/src/App/useAppState.ts
@@ -6,6 +6,7 @@ import { DEMO_MODE } from '../buildMode';
import { useWorkspaces } from './Workspace/useWorkspaces';
import type { SavedWorkspace } from '../Core/Workspace/Persistence';
import { appServices } from './appServices';
+import { parseLookupFragment } from './deepLink';
import {
createFeedback,
type Feedback,
@@ -68,6 +69,20 @@ export function useAppState() {
const [demoStartOpen, setDemoStartOpen] = useState(false);
const startWorkspace = () => (DEMO_MODE ? setDemoStartOpen(true) : setCreate('empty'));
const exampleNetworks = DEMO_MODE ? ['mainnet' as const] : discoveryError ? undefined : networks;
+ const [deepLinkRef, setDeepLinkRef] = useState();
+ // A #?s= fragment is external navigation intent: consume it once and clear it
+ // from the address bar so the reference never re-enters history or logs.
+ useEffect(() => {
+ const consume = () => {
+ const ref = parseLookupFragment(window.location.hash);
+ if (ref === undefined) return;
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`);
+ setDeepLinkRef(ref);
+ };
+ consume();
+ window.addEventListener('hashchange', consume);
+ return () => window.removeEventListener('hashchange', consume);
+ }, []);
const [notice, setNoticeValue] = useState();
const [noticeSequence, setNoticeSequence] = useState(0);
const setNotice = useCallback((feedback, options) => {
@@ -184,6 +199,8 @@ export function useAppState() {
setDeleteEntry,
examplesOpen,
setExamplesOpen,
+ deepLinkRef,
+ setDeepLinkRef,
notice,
setNotice,
noticeSequence,
diff --git a/tests/e2e/deeplink.spec.ts b/tests/e2e/deeplink.spec.ts
new file mode 100644
index 0000000..d7aeda4
--- /dev/null
+++ b/tests/e2e/deeplink.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test, type Page } from '@playwright/test';
+import { buildGraph } from '../../src/App/Workspace/GraphState/graphEvidence';
+import { createWorkspace } from '../../src/Core/Workspace/createWorkspace';
+import { createWorkspacePersistence } from '../../src/Core/Workspace/Persistence';
+import type { Workspace } from '../../src/Core/Workspace/workspace';
+
+const linkedTxid = 'c'.repeat(64);
+const password = 'deeplink-test-password';
+
+function deeplinkWorkspace(): Workspace {
+ const w = createWorkspace('Deeplink fixture', 'testnet4');
+ w.demo = false;
+ w.chainData.transactions[linkedTxid] = {
+ txid: linkedTxid,
+ vin: [{ coinbase: '00' }],
+ vout: [{ n: 0, value: 1, scriptPubKey: { type: 'witness_v0_keyhash' } }],
+ status: { kind: 'confirmed' as const, confirmations: 3 },
+ };
+ w.view.graphNodeIds = buildGraph(w).nodes.map((node) => node.id);
+ return w;
+}
+
+async function seedSavedWorkspace(page: Page, workspace: Workspace) {
+ const exported = await createWorkspacePersistence().exportFile(workspace, password);
+ const envelope: unknown = JSON.parse(exported.contents);
+ await page.addInitScript(
+ ({ id, name, envelope }) => {
+ localStorage.setItem('chaingraph.tour.seen', '1');
+ if (localStorage.getItem('chaingraph.encrypted-workspaces.v1') === null)
+ localStorage.setItem(
+ 'chaingraph.encrypted-workspaces.v1',
+ JSON.stringify([{ id, publicName: name, savedAt: new Date().toISOString(), envelope }]),
+ );
+ },
+ { id: workspace.id, name: workspace.name, envelope },
+ );
+}
+
+async function unlockSeededWorkspace(page: Page, name: string) {
+ await page.locator('.saved-row').filter({ hasText: name }).click();
+ const dialog = page.getByRole('dialog', { name: 'Unlock workspace' });
+ await dialog.getByLabel('Password', { exact: true }).fill(password);
+ await dialog.getByRole('button', { name: 'Unlock workspace', exact: true }).click();
+ await expect(page.getByTestId('graph-view')).toBeVisible();
+}
+
+test('a fragment reference adds to a workspace after unlock and clears the URL', async ({
+ page,
+}) => {
+ await seedSavedWorkspace(page, deeplinkWorkspace());
+ await page.goto(`/#?s=${linkedTxid}`);
+ const banner = page.getByTestId('deep-link-banner');
+ await expect(banner).toBeVisible();
+ await expect(page).not.toHaveURL(/#/);
+ await unlockSeededWorkspace(page, 'Deeplink fixture');
+ await banner.getByTestId('deep-link-banner-ref').click();
+ await expect(banner).toHaveCount(0);
+ await expect(page.locator('.toast.error')).toHaveCount(0);
+});
+
+test('a fragment reference on the home screen asks for a workspace and stays', async ({ page }) => {
+ await seedSavedWorkspace(page, deeplinkWorkspace());
+ await page.goto(`/#?s=${linkedTxid}`);
+ const banner = page.getByTestId('deep-link-banner');
+ await expect(banner).toBeVisible();
+ await banner.getByTestId('deep-link-banner-ref').click();
+ await expect(page.locator('.toast').filter({ hasText: 'workspace' })).toBeVisible();
+ await expect(banner).toBeVisible();
+});
+
+test('a fragment reference can be dismissed without adding', async ({ page }) => {
+ await seedSavedWorkspace(page, deeplinkWorkspace());
+ await page.goto(`/#?s=${linkedTxid}`);
+ const banner = page.getByTestId('deep-link-banner');
+ await expect(banner).toBeVisible();
+ await banner.getByRole('button', { name: 'Dismiss linked lookup' }).click();
+ await expect(banner).toHaveCount(0);
+});
diff --git a/tests/integration/workspace/deepLinkBanner.test.tsx b/tests/integration/workspace/deepLinkBanner.test.tsx
new file mode 100644
index 0000000..ae6bcbc
--- /dev/null
+++ b/tests/integration/workspace/deepLinkBanner.test.tsx
@@ -0,0 +1,411 @@
+// @vitest-environment jsdom
+import { useLayoutEffect } from 'react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { DeepLinkBanner } from '../../../src/App/DeepLinkBanner';
+import { ToastNotification } from '../../../src/App/Controls/ToastNotification';
+import { useAppState } from '../../../src/App/useAppState';
+import { useWorkspace } from '../../../src/App/Workspace/useWorkspace';
+import { appServices } from '../../../src/App/appServices';
+import { createWorkspace } from '../../../src/Core/Workspace/createWorkspace';
+import { parseVerboseTransaction } from '../../../src/Core/ChainData/verboseTransaction';
+import {
+ addressReference,
+ outpointReference,
+ transactionReference,
+} from '../../../src/Core/Workspace/entityReferences';
+import type { WorkbenchMode } from '../../../src/App/Workspace/workbenchTypes';
+import {
+ PUBLIC_ZPUB,
+ RECEIVE_ADDRESS,
+ TX_FUNDING,
+ TX_SPENDING,
+ transactions,
+} from '../../fixtures/bitcoin';
+import { installDomStubs } from '../../domStubs';
+
+installDomStubs();
+const policy = vi.hoisted(() => ({ connected: true, demo: false }));
+vi.mock('../../../src/buildMode', () => ({
+ get DEMO_MODE() {
+ return policy.demo;
+ },
+}));
+vi.mock('../../../src/App/useBackendNetworks', () => ({
+ useBackendNetworks: () => ({
+ networks: ['mainnet', 'testnet4'],
+ statuses: {
+ mainnet: { network: 'mainnet', connected: policy.connected },
+ testnet4: { network: 'testnet4', connected: policy.connected },
+ },
+ discoveryError: '',
+ }),
+}));
+vi.mock('../../../src/App/appServices', async () => {
+ const { WorkspaceStore } = await import('../../../src/Core/Workspace/Session/WorkspaceStore');
+ const persistence = {
+ initialError: '',
+ list: () => [],
+ assertCurrent: () => {},
+ save: vi.fn(async () => []),
+ load: vi.fn(),
+ remove: vi.fn(),
+ readFile: vi.fn(),
+ exportFile: vi.fn(),
+ };
+ return { appServices: { persistence, workspaceStore: new WorkspaceStore(persistence) } };
+});
+
+let current: { app: ReturnType; workspace: ReturnType };
+function Harness() {
+ const app = useAppState();
+ const workspace = useWorkspace(app);
+ useLayoutEffect(() => {
+ current = { app, workspace };
+ });
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function response(result: unknown) {
+ return new Response(JSON.stringify({ result }), {
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+function rpcCall(init?: RequestInit) {
+ return JSON.parse(String(init?.body)) as { method: string; params: unknown[] };
+}
+
+beforeEach(() => {
+ policy.connected = true;
+ policy.demo = false;
+ vi.stubEnv('SSR', true);
+ window.history.replaceState(null, '', '/');
+ localStorage.setItem('chaingraph.tour.seen', '1');
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const call = rpcCall(init);
+ if (call.method === 'getrawtransaction')
+ return response(transactions[call.params[0] as keyof typeof transactions]);
+ if (
+ call.method === 'blockchain.scripthash.get_history' ||
+ call.method === 'blockchain.scripthash.listunspent'
+ )
+ return response([]);
+ if (call.method === 'blockchain.scripthash.get_balance')
+ return response({ confirmed: 0, unconfirmed: 0 });
+ throw new Error('Unexpected synthetic RPC request');
+ }),
+ );
+});
+afterEach(async () => {
+ cleanup();
+ for (const session of appServices.workspaceStore.getSnapshot().unlocked) await session.lock();
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ localStorage.clear();
+});
+
+function setup({
+ cached = false,
+ workbench = 'graph',
+ wallet = false,
+}: {
+ cached?: boolean;
+ workbench?: WorkbenchMode;
+ wallet?: boolean;
+} = {}) {
+ const data = createWorkspace('Linked lookup fixture', 'mainnet');
+ data.view.workbench = workbench;
+ if (cached)
+ data.chainData.transactions[TX_FUNDING] = parseVerboseTransaction(transactions[TX_FUNDING]);
+ if (wallet || workbench === 'wallet')
+ data.wallets.definitions = [
+ {
+ id: '30000000-0000-4000-8000-000000000001',
+ name: 'Public vector wallet',
+ key: PUBLIC_ZPUB,
+ scriptType: 'p2wpkh',
+ color: '#27c4a7',
+ addresses: [],
+ scannedAt: '2026-10-01T10:00:00.000Z',
+ scanComplete: true,
+ },
+ ];
+ appServices.workspaceStore.open(data, 'public test password');
+ const view = render( );
+ return { ...view, id: data.id };
+}
+function receive(ref: string) {
+ act(() => {
+ window.history.pushState(null, '', `/#?s=${ref}`);
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
+ });
+ expect(window.location.hash).toBe('');
+}
+async function clickReference() {
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('deep-link-banner-ref'));
+ });
+}
+function expectSelection(id: string, selected: string) {
+ expect(current.workspace.selection.selectedId).toBe(selected);
+ expect(appServices.workspaceStore.getUnlocked(id)!.data.view.graphNodeIds).toContain(selected);
+}
+function delayTransaction() {
+ let release!: (value: Response) => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const normalFetch = vi.mocked(fetch).getMockImplementation()!;
+ vi.mocked(fetch).mockImplementation((input, init) =>
+ rpcCall(init).method === 'getrawtransaction' ? pending : normalFetch(input, init),
+ );
+ return () => release(response(transactions[TX_FUNDING]));
+}
+
+const references = [
+ { kind: 'transaction', ref: TX_FUNDING, selected: transactionReference(TX_FUNDING) },
+ { kind: 'output', ref: `${TX_FUNDING}:0`, selected: outpointReference(TX_FUNDING, 0) },
+ {
+ kind: 'address',
+ ref: RECEIVE_ADDRESS.toUpperCase(),
+ selected: addressReference(RECEIVE_ADDRESS),
+ },
+];
+
+it.each(references)(
+ 'dismisses after adding a new $kind through the lookup owner',
+ async ({ ref, selected }) => {
+ const view = setup();
+ receive(ref);
+ await clickReference();
+ expectSelection(view.id, selected);
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+ if (!selected.startsWith('addr:'))
+ expect(
+ appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING],
+ ).toBeDefined();
+ },
+);
+
+it.each([false, true])('opens loaded references without live lookup (demo=%s)', async (demo) => {
+ policy.demo = demo;
+ policy.connected = false;
+ const view = setup({ cached: true });
+ for (const { ref, selected } of references) {
+ receive(ref);
+ await clickReference();
+ expectSelection(view.id, selected);
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+ }
+ expect(fetch).not.toHaveBeenCalled();
+});
+
+it.each(references)(
+ 'reports an unavailable $kind and allows retry after reconnection',
+ async ({ ref, selected }) => {
+ policy.connected = false;
+ const view = setup();
+ receive(ref);
+ await clickReference();
+ expect(screen.getByRole('alert').textContent).toBeTruthy();
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ expect(current.workspace.selection.selectedId).toBeUndefined();
+ expect(fetch).not.toHaveBeenCalled();
+ policy.connected = true;
+ view.rerender( );
+ await clickReference();
+ expectSelection(view.id, selected);
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+ },
+);
+
+it('keeps a cached reference when foreground work declines the lookup, then retries', async () => {
+ const view = setup({ cached: true });
+ receive(TX_FUNDING);
+ let release!: () => void;
+ let foreground!: Promise;
+ await act(async () => {
+ foreground = current.workspace.operation.run(async () => {
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ });
+ });
+ try {
+ await clickReference();
+ expect(current.workspace.selection.selectedId).toBeUndefined();
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ } finally {
+ release();
+ await act(() => foreground);
+ }
+ await clickReference();
+ expectSelection(view.id, transactionReference(TX_FUNDING));
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+});
+
+it('preempts the default automatic wallet refresh and completes the linked lookup', async () => {
+ const normalFetch = vi.mocked(fetch).getMockImplementation()!;
+ let aborted = 0;
+ vi.mocked(fetch).mockImplementation((input, init) => {
+ if (rpcCall(init).method !== 'blockchain.scripthash.get_history')
+ return normalFetch(input, init);
+ return new Promise((_resolve, reject) => {
+ init!.signal!.addEventListener(
+ 'abort',
+ () => {
+ aborted++;
+ reject(new DOMException('Synthetic refresh aborted', 'AbortError'));
+ },
+ { once: true },
+ );
+ });
+ });
+ const view = setup({ wallet: true });
+ await waitFor(() => expect(current.workspace.wallet.discovery.refresh.loading).toBe(true));
+ await waitFor(() =>
+ expect(
+ vi
+ .mocked(fetch)
+ .mock.calls.some(
+ ([, init]) => rpcCall(init).method === 'blockchain.scripthash.get_history',
+ ),
+ ).toBe(true),
+ );
+ expect(current.workspace.wallet.discovery.monitorActivity).toBe(true);
+ receive(TX_FUNDING);
+ await clickReference();
+ expect(aborted).toBeGreaterThan(0);
+ expectSelection(view.id, transactionReference(TX_FUNDING));
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+});
+
+it('retains a newer fragment after an earlier network lookup completes', async () => {
+ const release = delayTransaction();
+ const view = setup();
+ receive(TX_FUNDING);
+ await clickReference();
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ receive(TX_SPENDING);
+ await act(async () => {
+ release();
+ });
+ await waitFor(() => expectSelection(view.id, transactionReference(TX_FUNDING)));
+ expect(current.app.deepLinkRef).toBe(TX_SPENDING);
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+});
+
+it.each(['cancel', 'switch workspace'] as const)(
+ 'retains the reference after a pending lookup is interrupted: %s',
+ async (action) => {
+ const release = delayTransaction();
+ const view = setup();
+ receive(TX_FUNDING);
+ await clickReference();
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ act(() => {
+ if (action === 'cancel') current.workspace.operation.cancel();
+ else
+ appServices.workspaceStore.open(
+ createWorkspace('Other workspace', 'testnet4'),
+ 'public test password',
+ );
+ });
+ await act(async () => {
+ release();
+ });
+ await waitFor(() => expect(current.workspace.operation.isActive()).toBe(false));
+ expect(
+ appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING],
+ ).toBeUndefined();
+ expect(current.app.deepLinkRef).toBe(TX_FUNDING);
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ },
+);
+
+it('retains a failed lookup and completes a subsequent retry', async () => {
+ const view = setup();
+ receive(TX_FUNDING);
+ const normalFetch = vi.mocked(fetch).getMockImplementation()!;
+ vi.mocked(fetch).mockRejectedValue(new Error('Synthetic unavailable backend'));
+ await clickReference();
+ expect(screen.getByRole('alert').textContent).toBeTruthy();
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ expect(current.workspace.selection.selectedId).toBeUndefined();
+ vi.mocked(fetch).mockImplementation(normalFetch);
+ await clickReference();
+ expectSelection(view.id, transactionReference(TX_FUNDING));
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+});
+
+it.each(
+ (['wallet', 'analysis'] as const).flatMap((workbench) =>
+ [false, true].map((cached) => ({ workbench, cached })),
+ ),
+)(
+ 'opens Graph after a successful lookup from $workbench (cached=$cached) and retains the return route',
+ async ({ workbench, cached }) => {
+ policy.connected = !cached;
+ const view = setup({ cached, workbench });
+ expect(current.workspace.workbench).toBe(workbench);
+ receive(TX_FUNDING);
+ await clickReference();
+ expectSelection(view.id, transactionReference(TX_FUNDING));
+ expect(current.workspace.workbench).toBe('graph');
+ expect(current.workspace.returnWorkbench).toBe(workbench);
+ expect(current.workspace.workbenchEntry?.target).toBe('stage');
+ expect(appServices.workspaceStore.getUnlocked(view.id)!.data.view.workbench).toBe('graph');
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+ },
+);
+
+it('completes ancestor loading before opening Graph from Analysis', async () => {
+ const view = setup({ workbench: 'analysis' });
+ act(() => current.workspace.setPrefetchDepth(1));
+ receive(TX_SPENDING);
+ await clickReference();
+ expectSelection(view.id, transactionReference(TX_SPENDING));
+ expect(
+ appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING],
+ ).toBeDefined();
+ expect(current.workspace.workbench).toBe('graph');
+ expect(current.workspace.returnWorkbench).toBe('analysis');
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+});
+
+it('stays in Analysis when the linked lookup cannot complete', async () => {
+ policy.connected = false;
+ setup({ workbench: 'analysis' });
+ receive(TX_FUNDING);
+ await clickReference();
+ expect(current.workspace.workbench).toBe('analysis');
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ expect(screen.getByRole('alert').textContent).toBeTruthy();
+});
+
+it('consumes the startup fragment, preserves anchors and can dismiss without an unlocked workspace', async () => {
+ window.history.replaceState(null, '', `/?keep=1#?s=${TX_FUNDING}`);
+ render( );
+ expect(window.location.hash).toBe('');
+ expect(window.location.search).toBe('?keep=1');
+ expect(current.app.deepLinkRef).toBe(TX_FUNDING);
+ await clickReference();
+ expect(current.app.notice).toBeDefined();
+ expect(screen.queryByTestId('deep-link-banner')).not.toBeNull();
+ act(() => {
+ window.history.pushState(null, '', '/#main-workspace');
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
+ });
+ expect(window.location.hash).toBe('#main-workspace');
+ expect(current.app.deepLinkRef).toBe(TX_FUNDING);
+ fireEvent.click(screen.getByRole('button', { name: /dismiss linked lookup/i }));
+ expect(screen.queryByTestId('deep-link-banner')).toBeNull();
+});