diff --git a/apps/order-book/frontend/App.tsx b/apps/order-book/frontend/App.tsx index dbc50fde..f7e096db 100644 --- a/apps/order-book/frontend/App.tsx +++ b/apps/order-book/frontend/App.tsx @@ -5,11 +5,10 @@ import { Deposit } from "./components/Deposit"; import { Header } from "./components/Header"; import { LiveBlocks } from "./components/LiveBlocks"; import { OrderBook } from "./components/OrderBook"; -import { AccountProvider, useAccountContext } from "./contexts/AccountContext"; -import { ManifestProvider } from "./contexts/ManifestContext"; import { useBalances } from "./hooks/useBalances"; import { useDemoSignUp } from "./hooks/useDemoSignUp"; import { useSignUp } from "./hooks/useSignUp"; +import { useStoredAccount } from "./hooks/useStoredAccount"; import "./index.css"; import { RouterProvider, useMatch, useRoute } from "./lib/router"; import { AboutOrderBook } from "./pages/AboutOrderBook"; @@ -53,7 +52,7 @@ function Auth() { } function OrderBookRoute() { - const { account, loading } = useAccountContext(); + const { data: account, isPending: loading } = useStoredAccount(); const balances = useBalances(account?.accountId); if (loading) return ; @@ -133,11 +132,7 @@ export function App() { return ( - - - - - + ); diff --git a/apps/order-book/frontend/components/Header.tsx b/apps/order-book/frontend/components/Header.tsx index 63f8b0be..f4ce6f11 100644 --- a/apps/order-book/frontend/components/Header.tsx +++ b/apps/order-book/frontend/components/Header.tsx @@ -1,8 +1,9 @@ import { TokenAmount, USD } from "order-book-sdk"; -import { useAccountContext } from "../contexts/AccountContext"; import { useAccount } from "../hooks/useAccount"; import { useBalances } from "../hooks/useBalances"; import { usePing } from "../hooks/usePing"; +import { useSignOut } from "../hooks/useSignOut"; +import { useStoredAccount } from "../hooks/useStoredAccount"; import { Link } from "../lib/router"; export function Header({ compact = false }: { compact?: boolean }) { @@ -43,7 +44,8 @@ function Ping() { } function HeaderAccountControls() { - const { account, setAccount } = useAccountContext(); + const { data: account } = useStoredAccount(); + const signOut = useSignOut(); const { data } = useBalances(account?.accountId); const accountQuery = useAccount(account?.accountId); @@ -67,7 +69,7 @@ function HeaderAccountControls() { ) : null}

- Create a P-256 account. The signing key stays in IndexedDB and only - the account ID and nonce are stored in localStorage. + Create a P-256 account. The account ID and signing key are stored + together in IndexedDB.

{error !== null ?

{error}

: null} @@ -464,7 +372,7 @@ export function App() {

Account Overview

- account ID: {account.accountID} + account ID: {account.accountId} balance: {formatUnits(info?.balance ?? 0n, 0)} nonce: {account.nonce.toString()} diff --git a/apps/token/frontend/hooks/useManifest.ts b/apps/token/frontend/hooks/useManifest.ts new file mode 100644 index 00000000..4ef7dc52 --- /dev/null +++ b/apps/token/frontend/hooks/useManifest.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import type { TokenManifest } from "../App"; +import { request } from "../lib/api"; + +export function useManifest() { + return useQuery({ + queryKey: ["manifest"], + queryFn: () => request("/api/manifest"), + staleTime: Number.POSITIVE_INFINITY, + }); +} diff --git a/apps/token/frontend/hooks/useStoredAccount.ts b/apps/token/frontend/hooks/useStoredAccount.ts new file mode 100644 index 00000000..2b7f0346 --- /dev/null +++ b/apps/token/frontend/hooks/useStoredAccount.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { deploymentKey, packNonce } from "typewriter/client"; +import { bytesToHex } from "viem"; +import { loadStoredAccount, type StoredAccount } from "../lib/account-store"; +import { useManifest } from "./useManifest"; + +export type Account = StoredAccount & { nonce: bigint }; + +function randomNonceLane(): bigint { + return BigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(24)))); +} + +export function toAccount(stored: StoredAccount): Account { + return { ...stored, nonce: packNonce(randomNonceLane(), 0n) }; +} + +async function loadAccount(storageKey: string): Promise { + const stored = await loadStoredAccount(storageKey); + if (stored === null) return null; + return toAccount(stored); +} + +export function useStoredAccount() { + const { data: manifest } = useManifest(); + const storageKey = + manifest === undefined + ? undefined + : deploymentKey(manifest.chainId, manifest.address); + return useQuery({ + queryKey: ["storedAccount", storageKey], + queryFn: () => loadAccount(storageKey!), + enabled: storageKey !== undefined, + staleTime: Number.POSITIVE_INFINITY, + }); +} diff --git a/apps/token/frontend/key-store.ts b/apps/token/frontend/lib/account-store.ts similarity index 68% rename from apps/token/frontend/key-store.ts rename to apps/token/frontend/lib/account-store.ts index c435e1f5..03e02b0d 100644 --- a/apps/token/frontend/key-store.ts +++ b/apps/token/frontend/lib/account-store.ts @@ -1,9 +1,13 @@ -const DATABASE = "typewriter-token"; -const STORE = "p256-key-pairs"; +import type { Hex } from "viem"; + +const DATABASE = "typewriter"; +const STORE = "account"; + +export type StoredAccount = { accountId: Hex; keyPair: CryptoKeyPair }; function openDatabase(): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE, 1); + const request = indexedDB.open(DATABASE); request.onupgradeneeded = () => { request.result.createObjectStore(STORE); }; @@ -12,13 +16,15 @@ function openDatabase(): Promise { }); } -export async function loadKeyPair(id: string): Promise { +export async function loadStoredAccount( + id: string, +): Promise { const database = await openDatabase(); try { return await new Promise((resolve, reject) => { const request = database.transaction(STORE).objectStore(STORE).get(id); request.onsuccess = () => - resolve((request.result as CryptoKeyPair | undefined) ?? null); + resolve((request.result as StoredAccount | undefined) ?? null); request.onerror = () => reject(request.error); }); } finally { @@ -26,15 +32,15 @@ export async function loadKeyPair(id: string): Promise { } } -export async function saveKeyPair( +export async function saveStoredAccount( id: string, - keyPair: CryptoKeyPair, + account: StoredAccount, ): Promise { const database = await openDatabase(); try { await new Promise((resolve, reject) => { const transaction = database.transaction(STORE, "readwrite"); - transaction.objectStore(STORE).put(keyPair, id); + transaction.objectStore(STORE).put(account, id); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); diff --git a/apps/token/frontend/lib/api.ts b/apps/token/frontend/lib/api.ts new file mode 100644 index 00000000..0706d40b --- /dev/null +++ b/apps/token/frontend/lib/api.ts @@ -0,0 +1,48 @@ +import superjson from "superjson"; + +export type RequestParams = { + method?: "GET" | "POST"; + body?: unknown; + cache?: RequestCache; +}; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +// Servers send errors as superjson `{ error }` or as plain text. +function errorMessage(text: string, path: string): string { + try { + const data = superjson.parse(text); + if (data !== null && typeof data === "object" && "error" in data) { + return String(data.error); + } + } catch {} + return text === "" ? `Request failed: ${path}` : text; +} + +export async function request( + path: string, + params: RequestParams = {}, +): Promise { + const response = await fetch(path, { + method: params.method ?? "GET", + cache: params.cache, + headers: + params.body === undefined + ? undefined + : { "Content-Type": "application/json" }, + body: + params.body === undefined ? undefined : superjson.stringify(params.body), + }); + const text = await response.text(); + if (!response.ok) { + throw new ApiError(errorMessage(text, path), response.status); + } + return (text === "" ? undefined : superjson.parse(text)) as T; +} diff --git a/packages/typewriter/src/client.test.ts b/packages/typewriter/src/client.test.ts index c60067e3..73824d29 100644 --- a/packages/typewriter/src/client.test.ts +++ b/packages/typewriter/src/client.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { AbiParameters, Hex } from "ox"; import { authorizeMutation, + deploymentKey, deriveAccountID, getAuthorizationPayload, incrementNonce, @@ -168,3 +169,12 @@ test("normalizes high-s P256 signatures when packing", () => { ), ).toEqual([BigInt(`0x${"11".repeat(32)}`), p256N - (p256N / 2n + 1n)]); }); + +test("derives a deployment key from the chain id and lowercase address", () => { + expect( + deploymentKey(10143, "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01"), + ).toBe("10143_0xabcdef0123456789abcdef0123456789abcdef01"); + expect(() => deploymentKey(-1, manifest.address)).toThrow(); + expect(() => deploymentKey(1.5, manifest.address)).toThrow(); + expect(() => deploymentKey(1, "0x1234" as never)).toThrow(); +}); diff --git a/packages/typewriter/src/client.ts b/packages/typewriter/src/client.ts index 74ea8540..da239fb4 100644 --- a/packages/typewriter/src/client.ts +++ b/packages/typewriter/src/client.ts @@ -1,5 +1,5 @@ import type { AbiParameter, AbiParameterToPrimitiveType } from "abitype"; -import { AbiParameters, Hash, Hex, TypedData } from "ox"; +import { AbiParameters, Address, Hash, Hex, TypedData } from "ox"; import type { TypewriterManifest } from "./config"; import { KeyType, packP256Signature } from "./signature"; import type { @@ -87,6 +87,22 @@ export function deriveAccountID(params: CreateAccountParams): Hex.Hex { ); } +/** + * Returns the key that identifies one deployment of a Typewriter app. The + * runtime uses it in the Postgres schema name, and clients use it to scope + * browser storage. + */ +export function deploymentKey( + chainId: number, + address: Address.Address, +): string { + if (!Number.isSafeInteger(chainId) || chainId < 0) { + throw new Error(`Invalid chain id: ${chainId}`); + } + Address.assert(address, { strict: false }); + return `${chainId}_${address.toLowerCase()}`; +} + export function incrementNonce(nonce: bigint): bigint { if (nonce < 0n || nonce > MAX_UINT256) { throw new RangeError("nonce must fit uint256"); diff --git a/packages/typewriter/src/db-query.test.ts b/packages/typewriter/src/db-query.test.ts index 890493ef..354b92b3 100644 --- a/packages/typewriter/src/db-query.test.ts +++ b/packages/typewriter/src/db-query.test.ts @@ -232,7 +232,7 @@ test("insertSlotWrites records raw slot writes", async () => { yield* db.transaction((tx) => insertSlotWrites(tx, schema, { id: 42 }, [ { - address: "0x000000000000000000000000000000000000typewriter", + address: "0x00000000000000000000000000000000000000ff", slot: "0x0000000000000000000000000000000000000000000000000000000000000001", prev_value: "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -240,7 +240,7 @@ test("insertSlotWrites records raw slot writes", async () => { "0x000000000000000000000000000000000000000000000000000000000000000a", }, { - address: "0x000000000000000000000000000000000000typewriter", + address: "0x00000000000000000000000000000000000000ff", slot: "0x0000000000000000000000000000000000000000000000000000000000000002", prev_value: "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -286,7 +286,7 @@ test("selectAccountStorage replays latest slot writes", async () => { yield* db.transaction((tx) => insertSlotWrites(tx, schema, { id: 1 }, [ { - address: "0x000000000000000000000000000000000000typewriter", + address: "0x00000000000000000000000000000000000000ff", slot, prev_value: "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -298,7 +298,7 @@ test("selectAccountStorage replays latest slot writes", async () => { yield* db.transaction((tx) => insertSlotWrites(tx, schema, { id: 2 }, [ { - address: "0x000000000000000000000000000000000000typewriter", + address: "0x00000000000000000000000000000000000000ff", slot, prev_value: "0x000000000000000000000000000000000000000000000000000000000000000a", @@ -323,7 +323,7 @@ test("selectAccountStorage uses slot writes after redeploy migration cleanup", a }); const chainId = 31341; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const schemaName = deploymentSchemaName(chainId, address); const slot = "0x0000000000000000000000000000000000000000000000000000000000000001"; diff --git a/packages/typewriter/src/migrate.test.ts b/packages/typewriter/src/migrate.test.ts index 608760de..a00f4382 100644 --- a/packages/typewriter/src/migrate.test.ts +++ b/packages/typewriter/src/migrate.test.ts @@ -5,7 +5,7 @@ import type { Address } from "ox"; import { TEST_DB_CONNECTION, TEST_DB_URL } from "../test/setup"; import { HARNESS_MUTATIONS } from "../test/utils"; import { layerDatabaseLive } from "./db"; -import { migrate } from "./migrate"; +import { deploymentSchemaName, migrate } from "./migrate"; import { createMutationSchema } from "./schema"; const runMigrate = ( @@ -41,9 +41,9 @@ const HARNESS_TABLES = [ test("migrate creates the configured schema", async () => { const chainId = 31337; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const testSchema = - "typewriter_31337_0x000000000000000000000000000000000000typewriter"; + "typewriter_31337_0x00000000000000000000000000000000000000ff"; const schemaName = await runMigrate(harnessSchema(), chainId, address, 0n); @@ -78,9 +78,9 @@ test("migrate creates the configured schema", async () => { test("migrate is idempotent when schema already exists", async () => { const chainId = 31338; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const testSchema = - "typewriter_31338_0x000000000000000000000000000000000000typewriter"; + "typewriter_31338_0x00000000000000000000000000000000000000ff"; const firstSchemaName = await runMigrate( harnessSchema(), @@ -110,9 +110,9 @@ test("migrate is idempotent when schema already exists", async () => { test("migrate deletes unsettled mutations in an existing schema", async () => { const chainId = 31339; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const schemaName = - "typewriter_31339_0x000000000000000000000000000000000000typewriter"; + "typewriter_31339_0x00000000000000000000000000000000000000ff"; await runMigrate(harnessSchema(), chainId, address, 0n); @@ -139,9 +139,9 @@ test("migrate deletes unsettled mutations in an existing schema", async () => { test("migrate deletes slot writes for unsettled mutations", async () => { const chainId = 31340; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const schemaName = - "typewriter_31340_0x000000000000000000000000000000000000typewriter"; + "typewriter_31340_0x00000000000000000000000000000000000000ff"; const schema = createMutationSchema({ mutations: { add: { @@ -188,9 +188,9 @@ test("migrate deletes slot writes for unsettled mutations", async () => { test("migrate removes newer unsettled slot writes for the same slot", async () => { const chainId = 31341; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const schemaName = - "typewriter_31341_0x000000000000000000000000000000000000typewriter"; + "typewriter_31341_0x00000000000000000000000000000000000000ff"; const schema = createMutationSchema({ mutations: { add: { @@ -248,9 +248,9 @@ test("migrate removes newer unsettled slot writes for the same slot", async () = test("migrate recovers accepted mutations below the onchain applied count", async () => { const chainId = 31342; const address = - "0x000000000000000000000000000000000000typewriter" as Address.Address; + "0x00000000000000000000000000000000000000ff" as Address.Address; const schemaName = - "typewriter_31342_0x000000000000000000000000000000000000typewriter"; + "typewriter_31342_0x00000000000000000000000000000000000000ff"; const schema = createMutationSchema({ mutations: { add: { @@ -308,3 +308,12 @@ test("migrate recovers accepted mutations below the onchain applied count", asyn }, ]); }); + +test("deploymentSchemaName rejects names that Postgres would truncate", () => { + const address = "0x1111111111111111111111111111111111111111"; + expect(deploymentSchemaName(10143, address)).toBe( + `typewriter_10143_${address}`, + ); + expect(deploymentSchemaName(999_999_999, address)).toHaveLength(63); + expect(() => deploymentSchemaName(1_000_000_000, address)).toThrow(); +}); diff --git a/packages/typewriter/src/migrate.ts b/packages/typewriter/src/migrate.ts index 5a1d9005..97d0e70b 100644 --- a/packages/typewriter/src/migrate.ts +++ b/packages/typewriter/src/migrate.ts @@ -10,6 +10,7 @@ import { isTable } from "drizzle-orm/table"; import { Data, Effect } from "effect"; import type { SqlError } from "effect/unstable/sql/SqlError"; import type { Address } from "ox"; +import { deploymentKey } from "./client"; import { Database, type DatabaseClient } from "./db"; import { deleteSlotWritesForUnsettledMutations, @@ -50,14 +51,20 @@ export function updateSchema( } } +// Postgres truncates longer identifiers without an error. +const MAX_IDENTIFIER_LENGTH = 63; + export function deploymentSchemaName( chainId: number, address: Address.Address, ): string { - if (!Number.isSafeInteger(chainId) || chainId < 0) { - throw new Error(`Invalid chain id: ${chainId}`); + const schemaName = `typewriter_${deploymentKey(chainId, address)}`; + if (schemaName.length > MAX_IDENTIFIER_LENGTH) { + throw new Error( + `Schema name is longer than ${MAX_IDENTIFIER_LENGTH} characters: ${schemaName}`, + ); } - return `typewriter_${chainId}_${address.toLowerCase()}`; + return schemaName; } function lockKey(schemaName: string): bigint {