diff --git a/build-glob.sh b/build-glob.sh new file mode 100755 index 0000000..879ed27 --- /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 && 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/components/Accounts.tsx b/src/components/Accounts.tsx index f1de0e6..26c61c5 100644 --- a/src/components/Accounts.tsx +++ b/src/components/Accounts.tsx @@ -16,7 +16,7 @@ import { useNetworks } from "../context/NetworksContext"; import ConfirmDialog from "./ConfirmDialog"; import { getNamespaces } from "../utils/wallet-connect/helpers"; import ShowPKDialog from "./ShowPKDialog"; -import { setInternetCredentials } from "../utils/key-store"; +import { updateNetworks } from "../utils/eyre-client"; import { Accordion, AccordionSummary, @@ -110,11 +110,7 @@ const Accounts = () => { (networkData) => selectedNetwork!.networkId !== networkData.networkId, ); - await setInternetCredentials( - "networks", - "_", - JSON.stringify(updatedNetworks), - ); + await updateNetworks(updatedNetworks); setSelectedNetwork(updatedNetworks[0]); setCurrentIndex(0); diff --git a/src/context/NetworksContext.tsx b/src/context/NetworksContext.tsx index ec311e6..7808651 100644 --- a/src/context/NetworksContext.tsx +++ b/src/context/NetworksContext.tsx @@ -1,9 +1,8 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { NetworksDataState } from '../types'; -import { retrieveNetworksData } from '../utils/accounts'; +import { getNetworks, updateNetworks } from '../utils/eyre-client'; import { DEFAULT_NETWORKS, EIP155 } from '../utils/constants'; -import { setInternetCredentials } from '../utils/key-store'; const NetworksContext = createContext<{ networksData: NetworksDataState[]; @@ -42,15 +41,10 @@ const NetworksProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { const fetchData = async () => { - let retrievedNetworks = await retrieveNetworksData(); + let retrievedNetworks = await getNetworks(); if (retrievedNetworks.length === 0) { - setInternetCredentials( - 'networks', - '_', - JSON.stringify(DEFAULT_NETWORKS_DATA), - ); - + await updateNetworks(DEFAULT_NETWORKS_DATA); retrievedNetworks = DEFAULT_NETWORKS_DATA; } diff --git a/src/screens/EditNetwork.tsx b/src/screens/EditNetwork.tsx index e82482a..538d109 100644 --- a/src/screens/EditNetwork.tsx +++ b/src/screens/EditNetwork.tsx @@ -10,9 +10,8 @@ import { } from "@react-navigation/native-stack"; import { useNavigation } from "@react-navigation/native"; -import { setInternetCredentials } from "../utils/key-store"; import { StackParamsList } from "../types"; -import { retrieveNetworksData } from "../utils/accounts"; +import { getNetworks, updateNetworks } from "../utils/eyre-client"; import { useNetworks } from "../context/NetworksContext"; import { COSMOS, @@ -74,7 +73,7 @@ const EditNetwork = ({ route }: EditNetworkProps) => { const submit = useCallback( async (data: z.infer) => { - const retrievedNetworksData = await retrieveNetworksData(); + const retrievedNetworksData = await getNetworks(); const { type, ...dataWithoutType } = data; const newNetworkData = { ...networkData, ...dataWithoutType }; const index = retrievedNetworksData.findIndex( @@ -83,11 +82,7 @@ const EditNetwork = ({ route }: EditNetworkProps) => { retrievedNetworksData.splice(index, 1, newNetworkData); - await setInternetCredentials( - "networks", - "_", - JSON.stringify(retrievedNetworksData), - ); + await updateNetworks(retrievedNetworksData); setNetworksData(retrievedNetworksData); 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/accounts.ts b/src/utils/accounts.ts index 380922f..369f5d8 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -7,106 +7,54 @@ import '@ethersproject/shims'; import { utils } from 'ethers'; import { HDNode } from 'ethers/lib/utils'; -import { - setInternetCredentials, - resetInternetCredentials, - getInternetCredentials, -} from './key-store'; import { Secp256k1HdWallet } from '@cosmjs/amino'; import { AccountData } from '@cosmjs/proto-signing'; import { stringToPath } from '@cosmjs/crypto'; import { Account, NetworksDataState, NetworksFormData } from '../types'; -import { - getHDPath, - getPathKey, - resetKeyServers, - updateAccountIndices, -} from './misc'; import { COSMOS, EIP155 } from './constants'; +import * as eyre from './eyre-client'; + +function splitNsChain(nsChain: string): [string, string] { + const i = nsChain.indexOf(':'); + return [nsChain.slice(0, i), nsChain.slice(i + 1)]; +} const createWallet = async ( networksData: NetworksDataState[], recoveryPhrase?: string, ): Promise => { const mnemonic = recoveryPhrase ? recoveryPhrase : utils.entropyToMnemonic(utils.randomBytes(16)); - - const hdNode = HDNode.fromMnemonic(mnemonic); - await setInternetCredentials('mnemonicServer', 'mnemonic', mnemonic); - - await createWalletFromMnemonic(networksData, hdNode, mnemonic); - + await eyre.createWallet(networksData, mnemonic); return mnemonic; }; const createWalletFromMnemonic = async ( networksData: NetworksDataState[], - hdNode: HDNode, + _hdNode: HDNode, mnemonic: string ): Promise => { - for (const network of networksData) { - const hdPath = `m/44'/${network.coinType}'/0'/0/0`; - const node = hdNode.derivePath(hdPath); - let address; - - switch (network.namespace) { - case EIP155: - address = node.address; - break; - - case COSMOS: - address = ( - await getCosmosAccountByHDPath(mnemonic, hdPath, network.addressPrefix) - ).data.address; - break; - - default: - throw new Error('Unsupported namespace'); - } - - const accountInfo = `${hdPath},${node.privateKey},${node.publicKey},${address}`; - - await Promise.all([ - setInternetCredentials( - `accounts/${network.namespace}:${network.chainId}/0`, - '_', - accountInfo, - ), - setInternetCredentials( - `addAccountCounter/${network.namespace}:${network.chainId}`, - '_', - '1', - ), - setInternetCredentials( - `accountIndices/${network.namespace}:${network.chainId}`, - '_', - '0', - ), - ]); - } + // HD derivation delegated to agent — hdNode param unused + await eyre.createWallet(networksData, mnemonic); }; const addAccount = async ( chainId: string, ): Promise => { try { - let selectedNetworkAccount - const networksData = await retrieveNetworksData(); + const networksData = await eyre.getNetworks(); - // Add account to all networks and return account for selected network + // Add account to all networks (agent handles derivation + counter) for (const network of networksData) { - const namespaceChainId = `${network.namespace}:${network.chainId}`; - const id = await getNextAccountId(namespaceChainId); - const hdPath = getHDPath(namespaceChainId, `0'/0/${id}`); - const account = await addAccountFromHDPath(hdPath, network); - await updateAccountCounter(namespaceChainId, id); - - if (network.chainId === chainId) { - selectedNetworkAccount = account; - } + await eyre.addAccount(network.namespace, network.chainId); } - return selectedNetworkAccount; + // Return the new account for the selected network + const selectedNetwork = networksData.find(n => n.chainId === chainId); + if (!selectedNetwork) return; + + const accounts = await eyre.getAccounts(selectedNetwork.namespace, chainId); + return accounts[accounts.length - 1]; } catch (error) { console.error('Error creating account:', error); } @@ -117,13 +65,8 @@ const addAccountsForNetwork = async ( numberOfAccounts: number, ): Promise => { try { - const namespaceChainId = `${network.namespace}:${network.chainId}`; - for (let i = 0; i < numberOfAccounts; i++) { - const id = await getNextAccountId(namespaceChainId); - const hdPath = getHDPath(namespaceChainId, `0'/0/${id}`); - await addAccountFromHDPath(hdPath, network); - await updateAccountCounter(namespaceChainId, id); + await eyre.addAccount(network.namespace, network.chainId); } } catch (error) { console.error('Error creating account:', error); @@ -135,26 +78,11 @@ const addAccountFromHDPath = async ( networkData: NetworksDataState, ): Promise => { try { - const account = await accountInfoFromHDPath(hdPath, networkData); - if (!account) { - throw new Error('Error while creating account'); - } + // Agent derives from stored mnemonic using the given HD path + await eyre.addAccountFromPath(networkData.namespace, networkData.chainId, hdPath); - const { privKey, pubKey, address } = account; - - const namespaceChainId = `${networkData.namespace}:${networkData.chainId}`; - - const index = (await updateAccountIndices(namespaceChainId)).index; - - await Promise.all([ - setInternetCredentials( - `accounts/${namespaceChainId}/${index}`, - '_', - `${hdPath},${privKey},${pubKey},${address}`, - ), - ]); - - return { index, pubKey, address, hdPath }; + const accounts = await eyre.getAccounts(networkData.namespace, networkData.chainId); + return accounts[accounts.length - 1]; } catch (error) { console.error(error); } @@ -163,160 +91,37 @@ const addAccountFromHDPath = async ( const addNewNetwork = async ( newNetworkData: NetworksFormData ): Promise => { - const mnemonicServer = await getInternetCredentials("mnemonicServer"); - const mnemonic = mnemonicServer; - - if (!mnemonic) { - throw new Error("Mnemonic not found"); - } - - const hdNode = HDNode.fromMnemonic(mnemonic); - - const hdPath = `m/44'/${newNetworkData.coinType}'/0'/0/0`; - const node = hdNode.derivePath(hdPath); - let address; - - switch (newNetworkData.namespace) { - case EIP155: - address = node.address; - break; - - case COSMOS: - address = ( - await getCosmosAccountByHDPath( - mnemonic, - hdPath, - newNetworkData.addressPrefix, - ) - ).data.address; - break; - - default: - throw new Error("Unsupported namespace"); - } - - const accountInfo = `${hdPath},${node.privateKey},${node.publicKey},${address}`; - - await Promise.all([ - setInternetCredentials( - `accounts/${newNetworkData.namespace}:${newNetworkData.chainId}/0`, - "_", - accountInfo, - ), - setInternetCredentials( - `addAccountCounter/${newNetworkData.namespace}:${newNetworkData.chainId}`, - "_", - "1", - ), - setInternetCredentials( - `accountIndices/${newNetworkData.namespace}:${newNetworkData.chainId}`, - "_", - "0", - ), - ]); - - const retrievedNetworksData = await storeNetworkData(newNetworkData); - - // Get number of accounts in first network - const nextAccountId = await getNextAccountId( - `${retrievedNetworksData[0].namespace}:${retrievedNetworksData[0].chainId}`, - ); - - const selectedNetwork = retrievedNetworksData.find( - (network) => network.chainId === newNetworkData.chainId, - ); - - await addAccountsForNetwork(selectedNetwork!, nextAccountId - 1); - - return retrievedNetworksData; -} + await eyre.addNetwork(newNetworkData); + return eyre.getNetworks(); +}; const storeNetworkData = async ( networkData: NetworksFormData, ): Promise => { - const networks = await getInternetCredentials('networks'); - let retrievedNetworks = []; - if (networks) { - retrievedNetworks = JSON.parse(networks!); - } - let networkId = 0; - if (retrievedNetworks.length > 0) { - networkId = retrievedNetworks[retrievedNetworks.length - 1].networkId + 1; - } - - const updatedNetworks: NetworksDataState[] = [ - ...retrievedNetworks, - { - ...networkData, - networkId: String(networkId), - }, - ]; - - await setInternetCredentials( - 'networks', - '_', - JSON.stringify(updatedNetworks), - ); - - return updatedNetworks; + await eyre.addNetwork(networkData); + return eyre.getNetworks(); }; const retrieveNetworksData = async (): Promise => { - const networks = await getInternetCredentials('networks'); - - if (!networks) { - return []; - } - - const parsedNetworks: NetworksDataState[] = JSON.parse(networks); - - return parsedNetworks; + return eyre.getNetworks(); }; export const retrieveAccountsForNetwork = async ( namespaceChainId: string, - accountsIndices: string, + _accountsIndices?: string, ): Promise => { - const accountsIndexArray = accountsIndices.split(','); - - const loadedAccounts = await Promise.all( - accountsIndexArray.map(async i => { - const { address, path, pubKey } = await getPathKey( - namespaceChainId, - Number(i), - ); - - const account: Account = { - index: Number(i), - pubKey, - address, - hdPath: path, - }; - - return account; - }), - ); - - return loadedAccounts; + const [ns, chain] = splitNsChain(namespaceChainId); + return eyre.getAccounts(ns, chain); }; const retrieveAccounts = async ( currentNetworkData: NetworksDataState, ): Promise => { - const accountIndicesServer = await getInternetCredentials( - `accountIndices/${currentNetworkData.namespace}:${currentNetworkData.chainId}`, + const accounts = await eyre.getAccounts( + currentNetworkData.namespace, + currentNetworkData.chainId, ); - const accountIndices = accountIndicesServer; - if (!accountIndices) { - return; - } - - const loadedAccounts = await retrieveAccountsForNetwork( - `${currentNetworkData.namespace}:${currentNetworkData.chainId}`, - accountIndices, - ) - - return loadedAccounts; + return accounts.length > 0 ? accounts : undefined; }; const retrieveSingleAccount = async ( @@ -324,37 +129,13 @@ const retrieveSingleAccount = async ( chainId: string, address: string, ) => { - let loadedAccounts; - - const accountIndicesServer = await getInternetCredentials( - `accountIndices/${namespace}:${chainId}`, - ); - const accountIndices = accountIndicesServer; - - if (!accountIndices) { - throw new Error('Indices for given chain not found'); - } - - loadedAccounts = await retrieveAccountsForNetwork( - `${namespace}:${chainId}`, - accountIndices, - ); - - if (!loadedAccounts) { - throw new Error('Accounts for given chain not found'); - } - - return loadedAccounts.find(account => account.address === address); + const accounts = await eyre.getAccounts(namespace, chainId); + return accounts.find(account => account.address === address); }; const resetWallet = async () => { try { - await Promise.all([ - resetInternetCredentials('mnemonicServer'), - resetKeyServers(EIP155), - resetKeyServers(COSMOS), - setInternetCredentials('networks', '_', JSON.stringify([])), - ]); + await eyre.resetWallet(); } catch (error) { console.error('Error resetting wallet:', error); throw error; @@ -367,12 +148,13 @@ const accountInfoFromHDPath = async ( ): Promise< { privKey: string; pubKey: string; address: string } | undefined > => { - const mnemonicStore = await getInternetCredentials('mnemonicServer'); - if (!mnemonicStore) { + // Phase 1: still derives in browser for API compat. + // Phase 2: this moves to the agent entirely. + const mnemonic = await eyre.getMnemonic(); + if (!mnemonic) { throw new Error('Mnemonic not found!'); } - const mnemonic = mnemonicStore; const hdNode = HDNode.fromMnemonic(mnemonic); const node = hdNode.derivePath(hdPath); @@ -397,36 +179,15 @@ const accountInfoFromHDPath = async ( }; const getNextAccountId = async (namespaceChainId: string): Promise => { - const idStore = await getInternetCredentials( - `addAccountCounter/${namespaceChainId}`, - ); - if (!idStore) { - throw new Error('Account id not found'); - } - - const accountCounter = idStore; - const nextCounter = Number(accountCounter); - return nextCounter; + const [ns, chain] = splitNsChain(namespaceChainId); + return eyre.getNextAccountId(ns, chain); }; const updateAccountCounter = async ( - namespaceChainId: string, - id: number, + _namespaceChainId: string, + _id: number, ): Promise => { - const idStore = await getInternetCredentials( - `addAccountCounter/${namespaceChainId}`, - ); - if (!idStore) { - throw new Error('Account id not found'); - } - - const updatedCounter = String(id + 1); - await resetInternetCredentials(`addAccountCounter/${namespaceChainId}`); - await setInternetCredentials( - `addAccountCounter/${namespaceChainId}`, - '_', - updatedCounter, - ); + // Agent manages account counter atomically via addAccount poke. }; const getCosmosAccountByHDPath = async ( @@ -448,23 +209,13 @@ const getCosmosAccountByHDPath = async ( const checkNetworkForChainID = async ( chainId: string, ): Promise => { - const networks = await getInternetCredentials('networks'); - - if (!networks) { - return false; - } - - const networksData: NetworksFormData[] = JSON.parse(networks); - - return networksData.some((network) => network.chainId === chainId); + const networks = await eyre.getNetworks(); + return networks.some((network) => network.chainId === chainId); } const isWalletCreated = async ( ): Promise => { - const mnemonicServer = await getInternetCredentials("mnemonicServer"); - const mnemonic = mnemonicServer; - - return mnemonic !== null; + return eyre.walletExists(); }; export { 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/src/utils/key-store.ts b/src/utils/key-store.ts index f71fdff..40688c7 100644 --- a/src/utils/key-store.ts +++ b/src/utils/key-store.ts @@ -1,3 +1,8 @@ +/** + * @deprecated Use eyre-client.ts instead. This module will be removed in Phase 2. + * All consumers have been migrated to eyre-client.ts typed API. + */ + const setInternetCredentials = (name:string, username:string, password:string) => { localStorage.setItem(name, password); }; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index d7846de..c19d809 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -9,20 +9,23 @@ import { stringToPath } from '@cosmjs/crypto'; import '@ethersproject/shims'; import { - getInternetCredentials, - resetInternetCredentials, - setInternetCredentials, -} from './key-store'; + getMnemonic as eyreGetMnemonic, + getAccounts, + getAccount, +} from './eyre-client'; import { EIP155 } from './constants'; -import { NetworksDataState } from '../types'; + +function splitNsChain(namespaceChainId: string): [string, string] { + const i = namespaceChainId.indexOf(':'); + return [namespaceChainId.slice(0, i), namespaceChainId.slice(i + 1)]; +} const getMnemonic = async (): Promise => { - const mnemonicStore = await getInternetCredentials('mnemonicServer'); - if (!mnemonicStore) { + const mnemonic = await eyreGetMnemonic(); + if (!mnemonic) { throw new Error('Mnemonic not found!'); } - const mnemonic = mnemonicStore; return mnemonic; }; @@ -53,22 +56,19 @@ const getPathKey = async ( pubKey: string; address: string; }> => { - const pathKeyStore = await getInternetCredentials( - `accounts/${namespaceChainId}/${accountId}`, - ); + const [ns, chain] = splitNsChain(namespaceChainId); + const account = await getAccount(ns, chain, accountId); - if (!pathKeyStore) { - throw new Error('Error while fetching counter'); + if (!account) { + throw new Error('Error while fetching account'); } - const pathKeyVal = pathKeyStore; - const pathkey = pathKeyVal.split(','); - const path = pathkey[0]; - const privKey = pathkey[1]; - const pubKey = pathkey[2]; - const address = pathkey[3]; - - return { path, privKey, pubKey, address }; + return { + path: account.hdPath, + privKey: account.privKey, + pubKey: account.pubKey, + address: account.address, + }; }; const getAccountIndices = async ( @@ -78,75 +78,35 @@ const getAccountIndices = async ( indices: number[]; index: number; }> => { - const counterStore = await getInternetCredentials( - `accountIndices/${namespaceChainId}`, - ); + const [ns, chain] = splitNsChain(namespaceChainId); + const accounts = await getAccounts(ns, chain); - if (!counterStore) { - throw new Error('Error while fetching counter'); + if (accounts.length === 0) { + throw new Error('Error while fetching accounts'); } - let accountIndices = counterStore; - const indices = accountIndices.split(',').map(Number); - const index = indices[indices.length - 1] + 1; + const indices = accounts.map(a => a.index); + const maxIndex = Math.max(...indices); - return { accountIndices, indices, index }; + return { + accountIndices: indices.join(','), + indices, + index: maxIndex + 1, + }; }; const updateAccountIndices = async ( namespaceChainId: string, ): Promise<{ accountIndices: string; index: number }> => { - const accountIndicesData = await getAccountIndices(namespaceChainId); - const accountIndices = accountIndicesData.accountIndices; - const index = accountIndicesData.index; - const updatedAccountIndices = `${accountIndices},${index.toString()}`; - - await resetInternetCredentials(`accountIndices/${namespaceChainId}`); - await setInternetCredentials( - `accountIndices/${namespaceChainId}`, - '_', - updatedAccountIndices, - ); - - return { accountIndices: updatedAccountIndices, index }; + // Agent manages account indices atomically via addAccount poke. + // This reads current state for callers that still expect the old return shape. + const data = await getAccountIndices(namespaceChainId); + return { accountIndices: data.accountIndices, index: data.index }; }; -const resetKeyServers = async (namespace: string) => { - const networksServer = await getInternetCredentials('networks'); - if (!networksServer) { - throw new Error('Networks not found.'); - } - - const networksData: NetworksDataState[] = JSON.parse(networksServer); - const filteredNetworks = networksData.filter( - network => network.namespace === namespace, - ); - - if (filteredNetworks.length === 0) { - throw new Error(`No networks found for namespace ${namespace}.`); - } - - filteredNetworks.forEach(async network => { - const { chainId } = network; - const namespaceChainId = `${namespace}:${chainId}`; - - const idStore = await getInternetCredentials( - `accountIndices/${namespaceChainId}`, - ); - if (!idStore) { - throw new Error(`Account indices not found for ${namespaceChainId}.`); - } - - const accountIds = idStore; - const ids = accountIds.split(',').map(Number); - const latestId = Math.max(...ids); - - for (let i = 0; i <= latestId; i++) { - await resetInternetCredentials(`accounts/${namespaceChainId}/${i}`); - } - await resetInternetCredentials(`addAccountCounter/${namespaceChainId}`); - await resetInternetCredentials(`accountIndices/${namespaceChainId}`); - }); +const resetKeyServers = async (_namespace: string) => { + // Agent manages account lifecycle atomically via resetWallet poke. + // Individual namespace cleanup is a no-op — resetWallet handles it all. }; const sendMessage = ( 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 +--