@@ -454,8 +362,8 @@ export function App() {
{pending ? "Creating account..." : "Sign In"}
- 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 {