[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 <noreply@anthropic.com>
This commit is contained in:
parent
490f4ec8a4
commit
fc82acb4d5
65
build-glob.sh
Executable file
65
build-glob.sh
Executable file
@ -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}"
|
||||
457
src/utils/__tests__/eyre-client.test.ts
Normal file
457
src/utils/__tests__/eyre-client.test.ts
Normal file
@ -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<string, Array<ESListener | ESErrorListener>> = {};
|
||||
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<EyreClient> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
441
src/utils/eyre-client.ts
Normal file
441
src/utils/eyre-client.ts
Normal file
@ -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<number, PendingPoke>();
|
||||
|
||||
// ============================================================
|
||||
// 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<string> {
|
||||
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<T>(path: string): Promise<T | null> {
|
||||
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<void> {
|
||||
const shipName = await getShipName();
|
||||
const id = ++messageId;
|
||||
|
||||
const ackPromise = new Promise<void>((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<boolean> {
|
||||
const data = await scryGet<{ exists: boolean }>('/wallet/exists');
|
||||
return data?.exists ?? false;
|
||||
}
|
||||
|
||||
async function getMnemonic(): Promise<string | null> {
|
||||
const data = await scryGet<{ mnemonic: string }>('/wallet/mnemonic');
|
||||
return data?.mnemonic ?? null;
|
||||
}
|
||||
|
||||
async function getNetworks(): Promise<NetworksDataState[]> {
|
||||
return (await scryGet<NetworksDataState[]>('/wallet/networks')) ?? [];
|
||||
}
|
||||
|
||||
async function getAccounts(
|
||||
namespace: string,
|
||||
chainId: string,
|
||||
): Promise<Account[]> {
|
||||
return (
|
||||
(await scryGet<Account[]>(`/wallet/accounts/${namespace}:${chainId}`)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async function getAccount(
|
||||
namespace: string,
|
||||
chainId: string,
|
||||
index: number,
|
||||
): Promise<AccountWithKeys | null> {
|
||||
return scryGet<AccountWithKeys>(
|
||||
`/wallet/account/${namespace}:${chainId}/${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getNextAccountId(
|
||||
namespace: string,
|
||||
chainId: string,
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
const payload: Record<string, unknown> = {
|
||||
action: 'create-wallet',
|
||||
networks,
|
||||
};
|
||||
if (mnemonic) {
|
||||
payload.mnemonic = mnemonic;
|
||||
}
|
||||
return pokeAction('json', payload);
|
||||
}
|
||||
|
||||
async function addAccount(
|
||||
namespace: string,
|
||||
chainId: string,
|
||||
): Promise<void> {
|
||||
return pokeAction('json', {
|
||||
action: 'add-account',
|
||||
namespace,
|
||||
'chain-id': chainId,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateNetworks(
|
||||
networks: NetworksDataState[],
|
||||
): Promise<void> {
|
||||
return pokeAction('json', {
|
||||
action: 'update-networks',
|
||||
networks,
|
||||
});
|
||||
}
|
||||
|
||||
async function addNetwork(network: NetworksFormData): Promise<void> {
|
||||
return pokeAction('json', {
|
||||
action: 'add-network',
|
||||
network,
|
||||
});
|
||||
}
|
||||
|
||||
async function addAccountFromPath(
|
||||
namespace: string,
|
||||
chainId: string,
|
||||
hdPath: string,
|
||||
): Promise<void> {
|
||||
return pokeAction('json', {
|
||||
action: 'add-account-from-path',
|
||||
namespace,
|
||||
'chain-id': chainId,
|
||||
'hd-path': hdPath,
|
||||
});
|
||||
}
|
||||
|
||||
async function resetWallet(): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
9
urbit-files/desk.docket-0
Normal file
9
urbit-files/desk.docket-0
Normal file
@ -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'
|
||||
==
|
||||
14
urbit-files/mar/ico.hoon
Normal file
14
urbit-files/mar/ico.hoon
Normal file
@ -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
|
||||
--
|
||||
14
urbit-files/mar/map.hoon
Normal file
14
urbit-files/mar/map.hoon
Normal file
@ -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
|
||||
--
|
||||
14
urbit-files/mar/ttf.hoon
Normal file
14
urbit-files/mar/ttf.hoon
Normal file
@ -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
|
||||
--
|
||||
14
urbit-files/mar/woff.hoon
Normal file
14
urbit-files/mar/woff.hoon
Normal file
@ -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
|
||||
--
|
||||
14
urbit-files/mar/woff2.hoon
Normal file
14
urbit-files/mar/woff2.hoon
Normal file
@ -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
|
||||
--
|
||||
Loading…
Reference in New Issue
Block a user