From fc82acb4d57562815bec0b3e657c0e0769a1ef03 Mon Sep 17 00:00:00 2001 From: afd Date: Tue, 10 Feb 2026 09:38:59 -0500 Subject: [PATCH] [wallet-integration 3/5] Add Eyre channel client and glob deployment New eyre-client.ts replaces localStorage with Urbit ship storage: - Base layer: scryGet(), pokeAction() with SSE ack/nack handling - Typed API: 11 functions for wallet CRUD (walletExists, getMnemonic, getNetworks, getAccounts, createWallet, addAccount, resetWallet, etc.) - Compatibility layer: getWalletData/setWalletData/deleteWalletData mapping old key patterns for incremental migration Glob deployment setup following Laconic self-hosted DeFi playbook: - build-glob.sh: build, lowercase, deploy to ship - urbit-files/: desk.docket-0 + mark files (ico, map, ttf, woff, woff2) Includes 22 Jest tests with mocked fetch/EventSource. Part of wallet-integration across: - zenith-desk: Hoon crypto libs + agent endpoints - zenith-wallet-web (this repo): Eyre channel client + localStorage migration - zenith-testing: Go integration tests Co-Authored-By: Claude Opus 4.6 --- build-glob.sh | 65 ++++ src/utils/__tests__/eyre-client.test.ts | 457 ++++++++++++++++++++++++ src/utils/eyre-client.ts | 441 +++++++++++++++++++++++ urbit-files/desk.docket-0 | 9 + urbit-files/mar/ico.hoon | 14 + urbit-files/mar/map.hoon | 14 + urbit-files/mar/ttf.hoon | 14 + urbit-files/mar/woff.hoon | 14 + urbit-files/mar/woff2.hoon | 14 + 9 files changed, 1042 insertions(+) create mode 100755 build-glob.sh create mode 100644 src/utils/__tests__/eyre-client.test.ts create mode 100644 src/utils/eyre-client.ts create mode 100644 urbit-files/desk.docket-0 create mode 100644 urbit-files/mar/ico.hoon create mode 100644 urbit-files/mar/map.hoon create mode 100644 urbit-files/mar/ttf.hoon create mode 100644 urbit-files/mar/woff.hoon create mode 100644 urbit-files/mar/woff2.hoon diff --git a/build-glob.sh b/build-glob.sh new file mode 100755 index 0000000..85b3b07 --- /dev/null +++ b/build-glob.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# +# Build and deploy zenith-wallet-web as an Urbit glob. +# +# Prerequisites: +# - yarn installed +# - An Urbit ship running with a pier at $PIER_PATH +# - The desk 'zenith-wallet' already created on the ship +# - IPFS node running (for glob upload) +# +# Usage: +# PIER_PATH=/path/to/pier ./build-glob.sh +# +set -euo pipefail + +DESK_NAME="zenith-wallet" + +if [ -z "${PIER_PATH:-}" ]; then + echo "Error: PIER_PATH must be set to your Urbit pier directory" + exit 1 +fi + +DESK_PATH="${PIER_PATH}/${DESK_NAME}" + +# 1. Build the React app +echo "==> Building React app..." +yarn build + +# 2. Lowercase all filenames in build/ (Urbit requires lowercase) +echo "==> Lowercasing filenames in build/..." +find build -depth -name '*[A-Z]*' | while read -r f; do + dir="$(dirname "$f")" + base="$(basename "$f")" + lower="$(echo "$base" | tr '[:upper:]' '[:lower:]')" + if [ "$base" != "$lower" ]; then + mv "$f" "${dir}/${lower}" + fi +done + +# 3. Copy build output to the ship's desk +echo "==> Copying build/ to ${DESK_PATH}..." +mkdir -p "${DESK_PATH}" +cp -r build/* "${DESK_PATH}/" + +# 4. Copy mark files for non-standard extensions +echo "==> Copying mark files..." +mkdir -p "${DESK_PATH}/mar" +cp urbit-files/mar/*.hoon "${DESK_PATH}/mar/" + +# 5. Copy desk.docket-0 +echo "==> Copying desk.docket-0..." +cp urbit-files/desk.docket-0 "${DESK_PATH}/desk.docket-0" + +echo "" +echo "==> Done! Next steps:" +echo " 1. In your ship's dojo, run:" +echo " |commit %${DESK_NAME}" +echo " 2. Create the glob:" +echo " -garden!make-glob %${DESK_NAME} /build" +echo " 3. Upload the glob file from .urb/put/ to IPFS" +echo " 4. Update desk.docket-0 with the IPFS URL and hash" +echo " 5. Recommit:" +echo " |commit %${DESK_NAME}" +echo " 6. Install the desk:" +echo " |install our %${DESK_NAME}" diff --git a/src/utils/__tests__/eyre-client.test.ts b/src/utils/__tests__/eyre-client.test.ts new file mode 100644 index 0000000..8358ca2 --- /dev/null +++ b/src/utils/__tests__/eyre-client.test.ts @@ -0,0 +1,457 @@ +/** + * Tests for eyre-client.ts — the Eyre HTTP client for %zenith agent. + * + * Mocks: global fetch, EventSource + * Strategy: jest.resetModules() + dynamic import per test group for clean + * module-level state (channelUid, cachedShipName, messageId, pendingPokes). + */ + +type EyreClient = typeof import('../eyre-client'); + +// --------------------------------------------------------------------------- +// Mock EventSource +// --------------------------------------------------------------------------- + +type ESListener = (ev: MessageEvent) => void; +type ESErrorListener = (ev: Event) => void; + +class MockEventSource { + static instance: MockEventSource | null = null; + + url: string; + withCredentials: boolean; + private listeners: Record> = {}; + close = jest.fn(); + + constructor(url: string, opts?: { withCredentials?: boolean }) { + this.url = url; + this.withCredentials = opts?.withCredentials ?? false; + MockEventSource.instance = this; + } + + addEventListener(type: string, cb: ESListener | ESErrorListener) { + (this.listeners[type] ??= []).push(cb); + } + + /** Simulate an SSE message event */ + emit(type: string, data: unknown, lastEventId = '1') { + for (const cb of this.listeners[type] ?? []) { + (cb as ESListener)({ + data: JSON.stringify(data), + lastEventId, + } as MessageEvent); + } + } + + emitError() { + for (const cb of this.listeners['error'] ?? []) { + (cb as ESErrorListener)(new Event('error')); + } + } +} + +// Install MockEventSource globally before any module loads +(global as any).EventSource = MockEventSource; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockFetchResponses( + ...responses: Array<{ status: number; body?: unknown; text?: string }> +) { + const impl = jest.fn(); + for (const r of responses) { + impl.mockResolvedValueOnce({ + ok: r.status >= 200 && r.status < 300, + status: r.status, + statusText: r.status === 404 ? 'Not Found' : 'OK', + json: () => Promise.resolve(r.body), + text: () => Promise.resolve(r.text ?? ''), + }); + } + global.fetch = impl; + return impl; +} + +async function loadModule(): Promise { + jest.resetModules(); + MockEventSource.instance = null; + return import('../eyre-client'); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('eyre-client', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ========================================================================= + // scryGet + // ========================================================================= + + describe('scryGet', () => { + it('returns parsed JSON on 200', async () => { + const { scryGet } = await loadModule(); + mockFetchResponses({ status: 200, body: { mnemonic: 'foo bar' } }); + + const result = await scryGet('/wallet/mnemonic'); + + expect(result).toEqual({ mnemonic: 'foo bar' }); + expect(global.fetch).toHaveBeenCalledWith( + '/~/scry/zenith/wallet/mnemonic.json', + { credentials: 'include' }, + ); + }); + + it('returns null on 404', async () => { + const { scryGet } = await loadModule(); + mockFetchResponses({ status: 404 }); + + const result = await scryGet('/wallet/nonexistent'); + + expect(result).toBeNull(); + }); + + it('throws on server error', async () => { + const { scryGet } = await loadModule(); + mockFetchResponses({ status: 500 }); + + await expect(scryGet('/wallet/broken')).rejects.toThrow( + 'Scry /wallet/broken failed: 500', + ); + }); + }); + + // ========================================================================= + // pokeAction + // ========================================================================= + + describe('pokeAction', () => { + it('sends correct channel PUT body and resolves on ack', async () => { + const { pokeAction } = await loadModule(); + + // First fetch: getShipName() → /~/host + // Second fetch: channel PUT + // Third fetch: ack PUT (from EventSource handler) + const fetchMock = mockFetchResponses( + { status: 200, text: '~sampel-palnet' }, + { status: 200 }, + { status: 200 }, + ); + + const pokePromise = pokeAction('json', { action: 'test' }); + + // Wait for fetch calls to settle + await new Promise((r) => setTimeout(r, 10)); + + // Verify channel PUT body + expect(fetchMock).toHaveBeenCalledTimes(2); + const putCall = fetchMock.mock.calls[1]; + expect(putCall[0]).toMatch(/^\/~\/channel\/wallet-\d+$/); + expect(putCall[1].method).toBe('PUT'); + + const putBody = JSON.parse(putCall[1].body); + expect(putBody).toHaveLength(1); + expect(putBody[0]).toMatchObject({ + action: 'poke', + ship: '~sampel-palnet', + app: 'zenith', + mark: 'json', + json: { action: 'test' }, + }); + + // Simulate poke ack from EventSource + const es = MockEventSource.instance!; + es.emit('message', { response: 'poke', id: putBody[0].id, ok: 'ok' }); + + await expect(pokePromise).resolves.toBeUndefined(); + }); + + it('rejects on poke nack', async () => { + const { pokeAction } = await loadModule(); + + mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 200 }, + { status: 200 }, + ); + + const pokePromise = pokeAction('json', { action: 'bad' }); + await new Promise((r) => setTimeout(r, 10)); + + const putBody = JSON.parse( + (global.fetch as jest.Mock).mock.calls[1][1].body, + ); + const es = MockEventSource.instance!; + es.emit('message', { + response: 'poke', + id: putBody[0].id, + ok: 'err', + err: 'Invalid mark', + }); + + await expect(pokePromise).rejects.toThrow('Invalid mark'); + }); + + it('rejects when channel PUT fails', async () => { + const { pokeAction } = await loadModule(); + + mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 500 }, + ); + + await expect( + pokeAction('json', { action: 'fail' }), + ).rejects.toThrow('Channel PUT failed: 500'); + }); + + it('rejects all pending pokes on EventSource error', async () => { + const { pokeAction } = await loadModule(); + + mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 200 }, + ); + + const pokePromise = pokeAction('json', { action: 'x' }); + await new Promise((r) => setTimeout(r, 10)); + + MockEventSource.instance!.emitError(); + + await expect(pokePromise).rejects.toThrow('Eyre channel disconnected'); + }); + }); + + // ========================================================================= + // getWalletData (compatibility layer) + // ========================================================================= + + describe('getWalletData', () => { + it('delegates mnemonicServer to scry /wallet/mnemonic', async () => { + const { getWalletData } = await loadModule(); + mockFetchResponses({ status: 200, body: { mnemonic: 'test phrase' } }); + + const result = await getWalletData('mnemonicServer'); + + expect(result).toBe('test phrase'); + expect(global.fetch).toHaveBeenCalledWith( + '/~/scry/zenith/wallet/mnemonic.json', + { credentials: 'include' }, + ); + }); + + it('returns null when no mnemonic exists', async () => { + const { getWalletData } = await loadModule(); + mockFetchResponses({ status: 404 }); + + expect(await getWalletData('mnemonicServer')).toBeNull(); + }); + + it('delegates networks to scry /wallet/networks', async () => { + const { getWalletData } = await loadModule(); + const networks = [{ networkId: 'zenith-1', networkName: 'Zenith' }]; + mockFetchResponses({ status: 200, body: networks }); + + const result = await getWalletData('networks'); + + expect(result).toBe(JSON.stringify(networks)); + expect(global.fetch).toHaveBeenCalledWith( + '/~/scry/zenith/wallet/networks.json', + { credentials: 'include' }, + ); + }); + + it('returns null when no networks exist', async () => { + const { getWalletData } = await loadModule(); + mockFetchResponses({ status: 200, body: [] }); + + expect(await getWalletData('networks')).toBeNull(); + }); + + it('delegates accounts/eip155:1/0 to scry /wallet/account/eip155:1/0', async () => { + const { getWalletData } = await loadModule(); + const account = { + index: 0, + hdPath: "m/44'/60'/0'/0/0", + privKey: '0xabc', + pubKey: '0xdef', + address: '0x123', + }; + mockFetchResponses({ status: 200, body: account }); + + const result = await getWalletData('accounts/eip155:1/0'); + + expect(result).toBe( + `${account.hdPath},${account.privKey},${account.pubKey},${account.address}`, + ); + expect(global.fetch).toHaveBeenCalledWith( + '/~/scry/zenith/wallet/account/eip155:1/0.json', + { credentials: 'include' }, + ); + }); + + it('returns null for nonexistent account', async () => { + const { getWalletData } = await loadModule(); + mockFetchResponses({ status: 404 }); + + expect(await getWalletData('accounts/eip155:1/99')).toBeNull(); + }); + + it('delegates accountIndices to scry /wallet/accounts', async () => { + const { getWalletData } = await loadModule(); + const accounts = [ + { index: 0, pubKey: 'a', address: 'b', hdPath: 'c' }, + { index: 1, pubKey: 'd', address: 'e', hdPath: 'f' }, + ]; + mockFetchResponses({ status: 200, body: accounts }); + + const result = await getWalletData('accountIndices/cosmos:zenith-1'); + + expect(result).toBe('0,1'); + }); + + it('delegates addAccountCounter to scry next-account-id', async () => { + const { getWalletData } = await loadModule(); + mockFetchResponses({ status: 200, body: { id: 3 } }); + + const result = await getWalletData('addAccountCounter/cosmos:zenith-1'); + + expect(result).toBe('3'); + }); + + it('returns null for unknown key', async () => { + const { getWalletData } = await loadModule(); + + expect(await getWalletData('unknown-key')).toBeNull(); + }); + }); + + // ========================================================================= + // setWalletData (compatibility layer) + // ========================================================================= + + describe('setWalletData', () => { + it('delegates networks write to poke with update-networks action', async () => { + const { setWalletData } = await loadModule(); + const networks = [{ networkId: 'zenith-1', networkName: 'Zenith' }]; + + // getShipName fetch, channel PUT, ack fetch + const fetchMock = mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 200 }, + { status: 200 }, + ); + + const promise = setWalletData('networks', JSON.stringify(networks)); + await new Promise((r) => setTimeout(r, 10)); + + // Verify the poke body + const putBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(putBody[0]).toMatchObject({ + action: 'poke', + mark: 'json', + json: { action: 'update-networks', networks }, + }); + + // Ack to resolve + MockEventSource.instance!.emit('message', { + response: 'poke', + id: putBody[0].id, + ok: 'ok', + }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('throws on direct mnemonic write', async () => { + const { setWalletData } = await loadModule(); + + await expect( + setWalletData('mnemonicServer', 'some words'), + ).rejects.toThrow('Cannot set mnemonic directly'); + }); + + it('throws on direct account write', async () => { + const { setWalletData } = await loadModule(); + + await expect( + setWalletData('accounts/eip155:1/0', 'data'), + ).rejects.toThrow('Direct write to'); + }); + + it('throws on unknown key', async () => { + const { setWalletData } = await loadModule(); + + await expect( + setWalletData('unknown', 'data'), + ).rejects.toThrow('Unknown wallet data key'); + }); + }); + + // ========================================================================= + // deleteWalletData + // ========================================================================= + + describe('deleteWalletData', () => { + it('delegates mnemonicServer delete to resetWallet poke', async () => { + const { deleteWalletData } = await loadModule(); + + const fetchMock = mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 200 }, + { status: 200 }, + ); + + const promise = deleteWalletData('mnemonicServer'); + await new Promise((r) => setTimeout(r, 10)); + + const putBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(putBody[0].json).toEqual({ action: 'reset-wallet' }); + + MockEventSource.instance!.emit('message', { + response: 'poke', + id: putBody[0].id, + ok: 'ok', + }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('is a no-op for account keys', async () => { + const { deleteWalletData } = await loadModule(); + + // Should resolve without making any fetch calls + await expect( + deleteWalletData('accounts/eip155:1/0'), + ).resolves.toBeUndefined(); + }); + }); + + // ========================================================================= + // closeChannel + // ========================================================================= + + describe('closeChannel', () => { + it('closes EventSource and rejects pending pokes', async () => { + const { pokeAction, closeChannel } = await loadModule(); + + mockFetchResponses( + { status: 200, text: '~zod' }, + { status: 200 }, + ); + + const pokePromise = pokeAction('json', { action: 'x' }); + await new Promise((r) => setTimeout(r, 10)); + + closeChannel(); + + await expect(pokePromise).rejects.toThrow('Channel closed'); + expect(MockEventSource.instance!.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/eyre-client.ts b/src/utils/eyre-client.ts new file mode 100644 index 0000000..375cad0 --- /dev/null +++ b/src/utils/eyre-client.ts @@ -0,0 +1,441 @@ +/** + * Eyre HTTP client for communicating with the %zenith agent. + * + * Replaces localStorage (key-store.ts) with Urbit ship storage via: + * - Scry: GET /~/scry/zenith/{path}.json + * - Poke: PUT /~/channel/{uid} with channel API + * + * Three layers: + * 1. Base helpers — scryGet(), pokeAction() + * 2. Typed API — getNetworks(), createWallet(), etc. + * 3. Compatibility — getWalletData(), setWalletData(), deleteWalletData() + */ + +import { Account, NetworksDataState, NetworksFormData } from '../types'; + +// ============================================================ +// Types +// ============================================================ + +export interface AccountWithKeys extends Account { + privKey: string; +} + +interface PendingPoke { + resolve: () => void; + reject: (err: Error) => void; +} + +// ============================================================ +// Channel state +// ============================================================ + +const APP_NAME = 'zenith'; +const POKE_TIMEOUT_MS = 30_000; + +let channelUid: string | null = null; +let eventSource: EventSource | null = null; +let messageId = 0; +let cachedShipName: string | null = null; +const pendingPokes = new Map(); + +// ============================================================ +// 1. Base helpers +// ============================================================ + +/** + * Discover the ship name. Uses /~/host (same as @urbit/http-api), + * with fallback to the Eyre auth cookie. + */ +async function getShipName(): Promise { + if (cachedShipName) return cachedShipName; + + const res = await fetch('/~/host', { credentials: 'include' }); + if (res.ok) { + cachedShipName = await res.text(); + return cachedShipName; + } + + const match = document.cookie.match(/urbauth-(~[a-z-]+)=/); + if (match) { + cachedShipName = match[1]; + return cachedShipName; + } + + throw new Error('Cannot determine ship name — not authenticated with Eyre'); +} + +function getChannelUrl(): string { + if (!channelUid) { + channelUid = `wallet-${Date.now()}`; + } + return `/~/channel/${channelUid}`; +} + +/** + * Open the SSE event source for receiving poke acks. + * Eyre buffers events until the client connects, so it's safe to open + * after the first PUT. + */ +function openEventSource(): void { + if (eventSource) return; + + eventSource = new EventSource(getChannelUrl(), { + withCredentials: true, + }); + + eventSource.addEventListener('message', (ev: MessageEvent) => { + const data = JSON.parse(ev.data); + + if (data.response === 'poke') { + const pending = pendingPokes.get(data.id); + if (pending) { + if (data.ok === 'ok') { + pending.resolve(); + } else { + pending.reject(new Error(data.err ?? 'Poke rejected by agent')); + } + pendingPokes.delete(data.id); + } + } + + // Acknowledge every SSE event to keep the channel alive + const ackId = ++messageId; + fetch(getChannelUrl(), { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([{ + id: ackId, + action: 'ack', + 'event-id': Number(ev.lastEventId), + }]), + }); + }); + + eventSource.addEventListener('error', () => { + for (const [, pending] of pendingPokes) { + pending.reject(new Error('Eyre channel disconnected')); + } + pendingPokes.clear(); + eventSource?.close(); + eventSource = null; + channelUid = null; + }); +} + +/** + * Scry the %zenith agent. Returns parsed JSON, or null on 404. + * + * @param path - Scry path starting with /, e.g. '/wallet/networks' + */ +async function scryGet(path: string): Promise { + const res = await fetch(`/~/scry/${APP_NAME}${path}.json`, { + credentials: 'include', + }); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`Scry ${path} failed: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +/** + * Poke the %zenith agent and wait for the ack/nack. + * + * @param mark - Poke mark, e.g. 'json' or 'wallet-action' + * @param json - Poke payload + */ +async function pokeAction(mark: string, json: unknown): Promise { + const shipName = await getShipName(); + const id = ++messageId; + + const ackPromise = new Promise((resolve, reject) => { + pendingPokes.set(id, { resolve, reject }); + setTimeout(() => { + if (pendingPokes.has(id)) { + pendingPokes.delete(id); + reject(new Error(`Poke timed out after ${POKE_TIMEOUT_MS}ms`)); + } + }, POKE_TIMEOUT_MS); + }); + + const res = await fetch(getChannelUrl(), { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([{ + id, + action: 'poke', + ship: shipName, + app: APP_NAME, + mark, + json, + }]), + }); + + if (!res.ok) { + pendingPokes.delete(id); + throw new Error(`Channel PUT failed: ${res.status} ${res.statusText}`); + } + + // Open SSE to receive ack (no-op if already open). + // Eyre buffers the ack until we connect. + openEventSource(); + + return ackPromise; +} + +// ============================================================ +// 2. Typed API — Reads (scry) +// ============================================================ + +async function walletExists(): Promise { + const data = await scryGet<{ exists: boolean }>('/wallet/exists'); + return data?.exists ?? false; +} + +async function getMnemonic(): Promise { + const data = await scryGet<{ mnemonic: string }>('/wallet/mnemonic'); + return data?.mnemonic ?? null; +} + +async function getNetworks(): Promise { + return (await scryGet('/wallet/networks')) ?? []; +} + +async function getAccounts( + namespace: string, + chainId: string, +): Promise { + return ( + (await scryGet(`/wallet/accounts/${namespace}:${chainId}`)) ?? [] + ); +} + +async function getAccount( + namespace: string, + chainId: string, + index: number, +): Promise { + return scryGet( + `/wallet/account/${namespace}:${chainId}/${index}`, + ); +} + +async function getNextAccountId( + namespace: string, + chainId: string, +): Promise { + const data = await scryGet<{ id: number }>( + `/wallet/next-account-id/${namespace}:${chainId}`, + ); + return data?.id ?? 0; +} + +// ============================================================ +// 2. Typed API — Writes (poke) +// ============================================================ + +async function createWallet( + networks: NetworksFormData[], + mnemonic?: string, +): Promise { + const payload: Record = { + action: 'create-wallet', + networks, + }; + if (mnemonic) { + payload.mnemonic = mnemonic; + } + return pokeAction('json', payload); +} + +async function addAccount( + namespace: string, + chainId: string, +): Promise { + return pokeAction('json', { + action: 'add-account', + namespace, + 'chain-id': chainId, + }); +} + +async function updateNetworks( + networks: NetworksDataState[], +): Promise { + return pokeAction('json', { + action: 'update-networks', + networks, + }); +} + +async function addNetwork(network: NetworksFormData): Promise { + return pokeAction('json', { + action: 'add-network', + network, + }); +} + +async function addAccountFromPath( + namespace: string, + chainId: string, + hdPath: string, +): Promise { + return pokeAction('json', { + action: 'add-account-from-path', + namespace, + 'chain-id': chainId, + 'hd-path': hdPath, + }); +} + +async function resetWallet(): Promise { + return pokeAction('json', { action: 'reset-wallet' }); +} + +// ============================================================ +// 3. Drop-in compatibility layer +// +// Maps the old localStorage key patterns to the typed API so +// existing callers can migrate incrementally in Phase 2. +// ============================================================ + +const ACCOUNTS_RE = /^accounts\/([^/]+)\/(\d+)$/; +const INDICES_RE = /^accountIndices\/(.+)$/; +const COUNTER_RE = /^addAccountCounter\/(.+)$/; + +function splitNsChain(nsChain: string): [string, string] { + const i = nsChain.indexOf(':'); + return [nsChain.slice(0, i), nsChain.slice(i + 1)]; +} + +/** + * Drop-in replacement for getInternetCredentials(key). + * Returns a string matching the old localStorage value format, or null. + */ +async function getWalletData(key: string): Promise { + if (key === 'mnemonicServer') { + return getMnemonic(); + } + + if (key === 'networks') { + const networks = await getNetworks(); + return networks.length > 0 ? JSON.stringify(networks) : null; + } + + const accountMatch = key.match(ACCOUNTS_RE); + if (accountMatch) { + const [ns, chain] = splitNsChain(accountMatch[1]); + const account = await getAccount(ns, chain, Number(accountMatch[2])); + if (!account) return null; + return `${account.hdPath},${account.privKey},${account.pubKey},${account.address}`; + } + + const indicesMatch = key.match(INDICES_RE); + if (indicesMatch) { + const [ns, chain] = splitNsChain(indicesMatch[1]); + const accounts = await getAccounts(ns, chain); + if (accounts.length === 0) return null; + return accounts.map((a) => a.index).join(','); + } + + const counterMatch = key.match(COUNTER_RE); + if (counterMatch) { + const [ns, chain] = splitNsChain(counterMatch[1]); + const id = await getNextAccountId(ns, chain); + return String(id); + } + + return null; +} + +/** + * Drop-in replacement for setInternetCredentials(key, _, value). + * Only the `networks` key supports direct writes — account mutations + * should use the typed API (createWallet, addAccount). + */ +async function setWalletData(key: string, value: string): Promise { + if (key === 'networks') { + const networks: NetworksDataState[] = JSON.parse(value); + return updateNetworks(networks); + } + + if (key === 'mnemonicServer') { + throw new Error( + 'Cannot set mnemonic directly — use createWallet() instead', + ); + } + + if (ACCOUNTS_RE.test(key) || INDICES_RE.test(key) || COUNTER_RE.test(key)) { + throw new Error( + `Direct write to '${key}' not supported — use createWallet() or addAccount()`, + ); + } + + throw new Error(`Unknown wallet data key: ${key}`); +} + +/** + * Drop-in replacement for resetInternetCredentials(key). + * Individual account key deletion is a no-op — the agent manages + * account lifecycle atomically via resetWallet(). + */ +async function deleteWalletData(key: string): Promise { + if (key === 'mnemonicServer') { + return resetWallet(); + } + // Account-related keys are managed atomically by the agent. + // Individual deletes are no-ops since resetWallet() handles cleanup. +} + +// ============================================================ +// 4. Channel lifecycle +// ============================================================ + +function closeChannel(): void { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + for (const [, pending] of pendingPokes) { + pending.reject(new Error('Channel closed')); + } + pendingPokes.clear(); + channelUid = null; +} + +// ============================================================ +// Exports +// ============================================================ + +export { + // Base helpers + scryGet, + pokeAction, + + // Typed API — reads + walletExists, + getMnemonic, + getNetworks, + getAccounts, + getAccount, + getNextAccountId, + + // Typed API — writes + createWallet, + addAccount, + updateNetworks, + addNetwork, + addAccountFromPath, + resetWallet, + + // Compatibility layer (Phase 1 drop-in for key-store.ts) + getWalletData, + setWalletData, + deleteWalletData, + + // Lifecycle + closeChannel, +}; diff --git a/urbit-files/desk.docket-0 b/urbit-files/desk.docket-0 new file mode 100644 index 0000000..14bdc8b --- /dev/null +++ b/urbit-files/desk.docket-0 @@ -0,0 +1,9 @@ +:~ title+'Zenith Wallet' + info+'Zenith blockchain wallet with WalletConnect support.' + color+0x4f.46e5 + base+'zenith-wallet' + glob-http+['REPLACE_WITH_GLOB_URL' REPLACE_WITH_GLOB_HASH] + version+[0 0 1] + website+'https://git.vdb.to/LaconicNetwork/zenith-wallet-web' + license+'MIT' +== diff --git a/urbit-files/mar/ico.hoon b/urbit-files/mar/ico.hoon new file mode 100644 index 0000000..616d1a0 --- /dev/null +++ b/urbit-files/mar/ico.hoon @@ -0,0 +1,14 @@ +:: mark for .ico icon files +:: +|_ dat=octs +++ grow + |% + ++ mime [/image/x-icon dat] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q) + ++ noun |=(=noun ;;(octs noun)) + -- +++ grad %mime +-- diff --git a/urbit-files/mar/map.hoon b/urbit-files/mar/map.hoon new file mode 100644 index 0000000..3e1ea92 --- /dev/null +++ b/urbit-files/mar/map.hoon @@ -0,0 +1,14 @@ +:: mark for .map files (source maps) +:: +|_ dat=octs +++ grow + |% + ++ mime [/application/json dat] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q) + ++ noun |=(=noun ;;(octs noun)) + -- +++ grad %mime +-- diff --git a/urbit-files/mar/ttf.hoon b/urbit-files/mar/ttf.hoon new file mode 100644 index 0000000..372c6d7 --- /dev/null +++ b/urbit-files/mar/ttf.hoon @@ -0,0 +1,14 @@ +:: mark for .ttf font files +:: +|_ dat=octs +++ grow + |% + ++ mime [/font/ttf dat] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q) + ++ noun |=(=noun ;;(octs noun)) + -- +++ grad %mime +-- diff --git a/urbit-files/mar/woff.hoon b/urbit-files/mar/woff.hoon new file mode 100644 index 0000000..401a817 --- /dev/null +++ b/urbit-files/mar/woff.hoon @@ -0,0 +1,14 @@ +:: mark for .woff font files +:: +|_ dat=octs +++ grow + |% + ++ mime [/font/woff dat] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q) + ++ noun |=(=noun ;;(octs noun)) + -- +++ grad %mime +-- diff --git a/urbit-files/mar/woff2.hoon b/urbit-files/mar/woff2.hoon new file mode 100644 index 0000000..b63a5c9 --- /dev/null +++ b/urbit-files/mar/woff2.hoon @@ -0,0 +1,14 @@ +:: mark for .woff2 font files +:: +|_ dat=octs +++ grow + |% + ++ mime [/font/woff2 dat] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q) + ++ noun |=(=noun ;;(octs noun)) + -- +++ grad %mime +--