From 638b1dc9f8ace95529f7d9b100426dbacdcaf570 Mon Sep 17 00:00:00 2001 From: kyscott18 <43524469+kyscott18@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:18:20 +0000 Subject: [PATCH] feat: key deployments by chain id and address Add deploymentKey(chainId, address) to typewriter/client. The runtime uses it for the Postgres schema name, and the token and order-book frontends use it as the browser storage key. Before this change, the order-book frontend used the address only, so deployments on different chains with the same address shared browser accounts. deploymentSchemaName throws when the name exceeds 63 characters, because Postgres truncates longer identifiers without an error. Give the token and order-book frontends the same account management: - lib/account-store.ts stores { accountId, keyPair } as one IndexedDB record, so a write cannot leave part of an account behind. - hooks/useManifest and hooks/useStoredAccount thinly wrap useQuery. Mutations update the stored account with setQueryData. - Each session signs on a random nonce lane, so the nonce is not stored and two tabs cannot use the same nonce. - Signing keys are not extractable. - lib/api.ts is shared, and reads plain-text error bodies. Order book drops its manifest and account contexts and the unused sign-in hook, signs with the session credential (ID 1), and gets a sign-out mutation that deletes the stored account. --- apps/order-book/frontend/App.tsx | 11 +- .../order-book/frontend/components/Header.tsx | 8 +- .../frontend/components/OrderBook.tsx | 4 +- .../frontend/contexts/AccountContext.tsx | 130 ---------- .../frontend/contexts/ManifestContext.tsx | 40 --- .../frontend/hooks/useDemoSignUp.ts | 38 ++- .../frontend/hooks/useDepositMutation.ts | 26 +- apps/order-book/frontend/hooks/useManifest.ts | 11 + .../frontend/hooks/useMarketOrderMutation.ts | 41 ++-- apps/order-book/frontend/hooks/useSign.ts | 11 +- apps/order-book/frontend/hooks/useSignIn.ts | 69 ------ apps/order-book/frontend/hooks/useSignOut.ts | 23 ++ apps/order-book/frontend/hooks/useSignUp.ts | 38 ++- .../frontend/hooks/useStoredAccount.ts | 35 +++ apps/order-book/frontend/lib/account-store.ts | 66 +++++ apps/order-book/frontend/lib/api.ts | 31 ++- apps/order-book/frontend/lib/sessionKey.ts | 16 -- .../frontend/lib/sessionKeyStore.ts | 60 ----- .../frontend/pages/AboutOrderBook.tsx | 4 +- apps/token/frontend/App.tsx | 228 ++++++------------ apps/token/frontend/hooks/useManifest.ts | 11 + apps/token/frontend/hooks/useStoredAccount.ts | 35 +++ .../{key-store.ts => lib/account-store.ts} | 22 +- apps/token/frontend/lib/api.ts | 48 ++++ packages/typewriter/src/client.test.ts | 10 + packages/typewriter/src/client.ts | 18 +- packages/typewriter/src/db-query.test.ts | 10 +- packages/typewriter/src/migrate.test.ts | 35 ++- packages/typewriter/src/migrate.ts | 13 +- 29 files changed, 506 insertions(+), 586 deletions(-) delete mode 100644 apps/order-book/frontend/contexts/AccountContext.tsx delete mode 100644 apps/order-book/frontend/contexts/ManifestContext.tsx create mode 100644 apps/order-book/frontend/hooks/useManifest.ts delete mode 100644 apps/order-book/frontend/hooks/useSignIn.ts create mode 100644 apps/order-book/frontend/hooks/useSignOut.ts create mode 100644 apps/order-book/frontend/hooks/useStoredAccount.ts create mode 100644 apps/order-book/frontend/lib/account-store.ts delete mode 100644 apps/order-book/frontend/lib/sessionKey.ts delete mode 100644 apps/order-book/frontend/lib/sessionKeyStore.ts create mode 100644 apps/token/frontend/hooks/useManifest.ts create mode 100644 apps/token/frontend/hooks/useStoredAccount.ts rename apps/token/frontend/{key-store.ts => lib/account-store.ts} (68%) create mode 100644 apps/token/frontend/lib/api.ts 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 {