diff --git a/apps/web/package.json b/apps/web/package.json index 32c9b33..3aeb861 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,27 +18,29 @@ "preview": "next build && next start", "spike:agent": "tsx src/server/agent/spike.ts", "start": "next start", + "test": "vitest run", "verify:channel": "tsx --env-file=.env src/server/tappy/verifyChannel.ts", + "verify:store": "tsx --env-file=.env src/server/tappy/verifyStore.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", + "@tappy/contracts": "workspace:*", + "@tappy/protocol": "workspace:*", "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "drizzle-orm": "^0.41.0", "next": "^15.2.3", + "openai": "^7.10.0", "postgres": "^3.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", "superjson": "^2.2.1", - "zod": "^3.24.2", - "@tappy/protocol": "workspace:*", - "@tappy/contracts": "workspace:*", "viem": "^2.21.0", - "openai": "^7.10.0" + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -56,7 +58,8 @@ "tailwindcss": "^4.0.15", "tsx": "^4.19.2", "typescript": "^5.8.2", - "typescript-eslint": "^8.27.0" + "typescript-eslint": "^8.27.0", + "vitest": "^2.1.9" }, "ct3aMetadata": { "initVersion": "7.40.0" diff --git a/apps/web/src/env.js b/apps/web/src/env.js index dfc1bb4..a614db1 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -19,6 +19,15 @@ export const env = createEnv({ .string() .regex(/^0x[0-9a-fA-F]{40}$/) .optional(), + /** + * Which key signs as the agent. `local` reads AGENT_PRIVATE_KEY; `privy` is cut line #4 and + * is not implemented yet. Testnet keys are checked in on purpose, so this is not a secret. + */ + AGENT_SIGNER: z.enum(["local", "privy"]).default("local"), + AGENT_PRIVATE_KEY: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/) + .optional(), NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), @@ -41,6 +50,8 @@ export const env = createEnv({ DATABASE_URL: process.env.DATABASE_URL, BRIDGE_TOKEN: process.env.BRIDGE_TOKEN, HUMAN_ADDRESS: process.env.HUMAN_ADDRESS, + AGENT_SIGNER: process.env.AGENT_SIGNER, + AGENT_PRIVATE_KEY: process.env.AGENT_PRIVATE_KEY, NODE_ENV: process.env.NODE_ENV, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, }, diff --git a/apps/web/src/server/agent/README.md b/apps/web/src/server/agent/README.md index 83c81b8..819ea48 100644 --- a/apps/web/src/server/agent/README.md +++ b/apps/web/src/server/agent/README.md @@ -1,10 +1,25 @@ The agent tool-calling loop. See `docs/workstreams/app.md`. -- `loop.ts` — the agent loop. Cap it at 6 tool round-trips. -- `tools.ts` — tool definitions (SPEC §3.5). Parse inputs with `JSON.parse`, never string matching. -- `prompt.ts` — the system prompt. It must tell the model it can only propose, and never to claim - success before status is `EXECUTED`. - -Model is `gpt-5.6-terra` via the OpenAI SDK (`openai`). Tool calling must go through -`client.responses.create` — GPT-5.6 rejects function tools on `/v1/chat/completions` -while reasoning is on. `arguments` comes back as a JSON string, so `JSON.parse` it. +Built: + +- `loop.ts` — `runAgentTurn`. One user message in, one reply out, up to 6 tool round trips. Takes + the client as an interface so tests drive it without a key. +- `tools.ts` — the six definitions from SPEC §3.5, their zod schemas, and the `AgentTools` + interface the loop calls. +- `prompt.ts` — the system prompt. +- `handlers.ts` — `createAgentTools`, which wires the tools to the proposal store and the signer. +- `spike.ts` — spike 2, unrun. Needs `OPENAI_API_KEY`. + +Model is `gpt-5.6-terra` via the OpenAI SDK. Tool calling must go through +`client.responses.create`; GPT-5.6 rejects function tools on `/v1/chat/completions` while +reasoning is on. `arguments` comes back as a JSON string, so `JSON.parse` it and then validate. +`reasoning.effort` defaults to `medium`, which is the main cost lever; the loop uses `low`. + +Not wired yet: + +- `ChainReader` and `ShopReader` in `handlers.ts` are interfaces, because nothing is deployed + (issue #5) and the shop tables do not exist. One implementation each and the rest is unchanged. +- No tRPC procedure calls `runAgentTurn` yet. That is `chat.send`. + +A failing tool comes back to the model as a result rather than an exception, because the user has +to be told. "No approval device connected" is an answer; a thrown error is silence. diff --git a/apps/web/src/server/agent/handlers.ts b/apps/web/src/server/agent/handlers.ts new file mode 100644 index 0000000..b368f1e --- /dev/null +++ b/apps/web/src/server/agent/handlers.ts @@ -0,0 +1,211 @@ +/** + * The tools, wired to the store and the signer. + * + * Everything that needs a chain goes through `ChainReader`, which is an interface rather than a + * viem client because nothing is deployed yet (issue #5). When the deploy lands, someone writes + * one implementation and no other file changes. + * + * The rules enforced here rather than in the model's prompt, because a prompt is not a guard: + * - No signer connected, no proposal. It fails loudly and never queues. + * - One proposal pending at a time; the store refuses the second and hands back the first. + */ + +import { formatUnits, parseUnits } from "viem"; +import type { Address } from "viem"; + +import { chainByKey, type Action } from "@tappy/protocol"; + +import type { db as Database } from "~/server/db"; +import type { AgentSigner } from "~/server/tappy/agentSigner"; +import { buildProposal } from "~/server/tappy/proposals"; +import { + PendingProposalError, + getProposal, + insertProposal, + listProposals, +} from "~/server/tappy/store"; +import type { + AgentTools, + ProposalSummary, + ProposeResult, + WalletSummary, +} from "./tools"; + +type Db = typeof Database; + +/** The chain reads the tools need. One implementation, once something is deployed. */ +export interface ChainReader { + /** `TappyGate.nonce()`. Guessing it produces a proposal that reverts. */ + nonce(): Promise; + /** The gate's native balance, in wei. */ + balance(): Promise; + /** `MockSwap.quote(valueIn)`, used when the model does not name a minimum. */ + quoteSwap(sellWei: bigint): Promise; +} + +/** The shop's side of `propose_buy`. The server reads the price; the model never supplies one. */ +export interface ShopReader { + item(itemId: string): Promise<{ invoiceId: string; itemName: string; priceWei: bigint; merchant: Address; shopUrl: string } | undefined>; +} + +export interface AgentToolDeps { + db: Db; + signer: AgentSigner; + chain: ChainReader; + shop: ShopReader; + gate: Address; + /** The demo DEX and the token it pays out. Required, so a swap cannot quietly target the gate. */ + dex: Address; + tokenOut: Address; + chainKey: string; + /** Whether the bridge is polling. Checked on every propose, not cached. */ + signerConnected: () => Promise<{ connected: boolean; address?: string }>; + /** How much slippage to allow when the model does not name a minimum. 1% by default. */ + swapSlippageBps?: number; +} + +export function createAgentTools(deps: AgentToolDeps): AgentTools { + const chain = chainByKey(deps.chainKey); + const decimals = chain.nativeDecimals; + const slippageBps = BigInt(deps.swapSlippageBps ?? 100); + + /** + * Building a proposal the human cannot be shown is worse than refusing: the row would sit + * PENDING_HUMAN, block every later proposal, and expire ten minutes later with no explanation. + */ + async function requireSigner(): Promise { + const status = await deps.signerConnected(); + if (!status.connected) { + throw new Error( + "No approval device connected. Nothing can be proposed until the human's Flipper is plugged in and the bridge is running.", + ); + } + } + + async function propose(action: Action, summary: string): Promise { + await requireSigner(); + const { proposal, view } = await buildProposal( + { + action, + chainId: chain.chainId, + gate: deps.gate, + nonce: await deps.chain.nonce(), + chainKey: deps.chainKey, + originator: "chat", + }, + deps.signer, + ); + + try { + const stored = await insertProposal(deps.db, proposal, view); + return { proposalId: stored.id, status: stored.status, summary }; + } catch (error) { + if (error instanceof PendingProposalError) { + // Tell the model what is already on the device. Retrying would only collide again. + throw new Error( + `Proposal ${error.pending.id} (${describe(error.pending.action, chain.nativeSymbol, decimals)}) ` + + "is already waiting on the human's device. It has to be approved or rejected before another can be made.", + ); + } + throw error; + } + } + + function summarise(id: string, status: ProposalSummary["status"], text: string): ProposalSummary { + return { proposalId: id, status, summary: text }; + } + + return { + async get_wallet(): Promise { + const [balance, signer, agent] = await Promise.all([ + deps.chain.balance(), + deps.signerConnected(), + deps.signer.address(), + ]); + return { + gate: deps.gate, + chain: chain.name, + balance: `${formatUnits(balance, decimals)} ${chain.nativeSymbol}`, + agent, + human: signer.address ?? "unknown", + signerConnected: signer.connected, + }; + }, + + propose_send(input) { + const valueWei = parseUnits(input.amountEth, decimals); + return propose( + { kind: "send", to: input.to, valueWei, ...(input.memo ? { memo: input.memo } : {}) }, + `Send ${input.amountEth} ${chain.nativeSymbol} to ${input.to}. Waiting on the human's device.`, + ); + }, + + async propose_swap(input) { + const sellWei = parseUnits(input.sellEth, decimals); + // A missing minimum is not zero. Zero means "accept any amount of tokens", which is the one + // thing a swap must never do. + const minBuy = input.minTokensOut + ? parseUnits(input.minTokensOut, decimals) + : ((await deps.chain.quoteSwap(sellWei)) * (10_000n - slippageBps)) / 10_000n; + + return propose( + { + kind: "swap", + dex: deps.dex, + sellWei, + minBuy, + tokenOut: deps.tokenOut, + }, + `Swap ${input.sellEth} ${chain.nativeSymbol} for at least ${formatUnits(minBuy, decimals)} tokens. Waiting on the human's device.`, + ); + }, + + async propose_buy(input) { + const item = await deps.shop.item(input.itemId); + if (!item) throw new Error(`No shop item ${input.itemId}.`); + + return propose( + { + kind: "buy", + merchant: item.merchant, + valueWei: item.priceWei, + invoiceId: item.invoiceId, + itemName: item.itemName, + shopUrl: item.shopUrl, + }, + `Buy ${item.itemName} for ${formatUnits(item.priceWei, decimals)} ${chain.nativeSymbol}. Waiting on the human's device.`, + ); + }, + + async get_proposal(input) { + const p = await getProposal(deps.db, input.proposalId); + if (!p) throw new Error(`No proposal ${input.proposalId}.`); + return { + proposalId: p.id, + status: p.status, + summary: describe(p.action, chain.nativeSymbol, decimals), + ...(p.txHash ? { txHash: p.txHash } : {}), + ...(p.error ? { error: p.error } : {}), + }; + }, + + async list_proposals(input) { + const rows = await listProposals(deps.db, input.limit ?? 20); + return rows.map((p) => + summarise(p.id, p.status, describe(p.action, chain.nativeSymbol, decimals)), + ); + }, + }; +} + +/** Prose for the model. The human's version of this is `toView`, and it is not this. */ +function describe(action: Action, symbol: string, decimals: number): string { + switch (action.kind) { + case "send": + return `Send ${formatUnits(action.valueWei, decimals)} ${symbol} to ${action.to}`; + case "swap": + return `Swap ${formatUnits(action.sellWei, decimals)} ${symbol} for at least ${formatUnits(action.minBuy, decimals)} tokens`; + case "buy": + return `Buy ${action.itemName} for ${formatUnits(action.valueWei, decimals)} ${symbol}`; + } +} diff --git a/apps/web/src/server/agent/loop.test.ts b/apps/web/src/server/agent/loop.test.ts new file mode 100644 index 0000000..a3d7c17 --- /dev/null +++ b/apps/web/src/server/agent/loop.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from "vitest"; +import type OpenAI from "openai"; + +import { MAX_TOOL_ROUND_TRIPS, runAgentTurn, type ResponsesClient } from "./loop"; +import { SYSTEM_PROMPT } from "./prompt"; +import { TOOL_DEFINITIONS, type AgentTools } from "./tools"; + +/* ---- fakes ---------------------------------------------------------------- */ + +function toolCall(name: string, args: unknown, callId = `call_${name}`) { + return { type: "function_call", name, call_id: callId, arguments: JSON.stringify(args) }; +} + +function message(text: string) { + return { type: "message", role: "assistant", content: [{ type: "output_text", text }] }; +} + +/** A client that replays a scripted list of responses, one per `create` call. */ +function scriptedClient(turns: { output: unknown[]; text?: string }[]) { + const calls: OpenAI.Responses.ResponseCreateParamsNonStreaming[] = []; + let i = 0; + const client: ResponsesClient = { + responses: { + create: vi.fn(async (body) => { + calls.push(body); + const turn = turns[i++] ?? { output: [], text: "" }; + return { + output: turn.output, + output_text: turn.text ?? "", + usage: { input_tokens: 10, output_tokens: 5 }, + } as unknown as OpenAI.Responses.Response; + }), + }, + }; + return { client, calls }; +} + +function fakeTools(overrides: Partial = {}): AgentTools { + return { + get_wallet: vi.fn(async () => ({ + gate: "0x1111111111111111111111111111111111111111", + chain: "Sepolia", + balance: "0.49 ETH", + agent: "0x2222222222222222222222222222222222222222", + human: "0x3333333333333333333333333333333333333333", + signerConnected: true, + })), + propose_send: vi.fn(async () => ({ + proposalId: `0x${"ab".repeat(32)}`, + status: "PENDING_HUMAN" as const, + summary: "Send 0.01 ETH. Waiting on the human's device.", + })), + propose_swap: vi.fn(async () => { + throw new Error("not used"); + }), + propose_buy: vi.fn(async () => { + throw new Error("not used"); + }), + get_proposal: vi.fn(async () => ({ + proposalId: `0x${"ab".repeat(32)}`, + status: "PENDING_HUMAN" as const, + summary: "Send 0.01 ETH", + })), + list_proposals: vi.fn(async () => []), + ...overrides, + }; +} + +const RECIPIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + +/* ---- tests ---------------------------------------------------------------- */ + +describe("runAgentTurn", () => { + it("returns the reply without calling a tool when the model does not ask for one", async () => { + const { client, calls } = scriptedClient([{ output: [message("What address?")], text: "What address?" }]); + const tools = fakeTools(); + + const turn = await runAgentTurn("Send some money to my friend.", { client, tools }); + + expect(turn.text).toBe("What address?"); + expect(turn.toolCalls).toEqual([]); + expect(turn.roundTrips).toBe(0); + expect(calls).toHaveLength(1); + expect(tools.propose_send).not.toHaveBeenCalled(); + }); + + it("runs the tool, feeds the result back, and answers", async () => { + const { client, calls } = scriptedClient([ + { output: [toolCall("propose_send", { to: RECIPIENT, amountEth: "0.01", memo: null })] }, + { output: [message("It is waiting on your Flipper.")], text: "It is waiting on your Flipper." }, + ]); + const tools = fakeTools(); + + const turn = await runAgentTurn(`Send 0.01 ETH to ${RECIPIENT}.`, { client, tools }); + + expect(tools.propose_send).toHaveBeenCalledWith({ to: RECIPIENT, amountEth: "0.01", memo: null }); + expect(turn.roundTrips).toBe(1); + expect(turn.text).toBe("It is waiting on your Flipper."); + expect(turn.toolCalls[0]?.output).toMatchObject({ status: "PENDING_HUMAN" }); + + // The second request has to carry the call and its output, or the model has no idea what happened. + const second = calls[1]!.input as OpenAI.Responses.ResponseInputItem[]; + expect(second.some((i) => "type" in i && i.type === "function_call")).toBe(true); + expect(second.some((i) => "type" in i && i.type === "function_call_output")).toBe(true); + }); + + it("sends the system prompt and the tool definitions on every request", async () => { + const { client, calls } = scriptedClient([{ output: [message("hi")], text: "hi" }]); + await runAgentTurn("hi", { client, tools: fakeTools() }); + + expect(calls[0]!.instructions).toBe(SYSTEM_PROMPT); + expect(calls[0]!.tools).toEqual(TOOL_DEFINITIONS); + expect(calls[0]!.reasoning).toEqual({ effort: "low" }); + }); + + // A handler throwing is the "no approval device connected" path. The user has to be told, so + // the error goes back to the model rather than ending the turn in silence. + it("hands a failing tool back to the model as a result, not an exception", async () => { + const { client } = scriptedClient([ + { output: [toolCall("propose_send", { to: RECIPIENT, amountEth: "0.01", memo: null })] }, + { output: [message("No device is connected.")], text: "No device is connected." }, + ]); + const tools = fakeTools({ + propose_send: vi.fn(async () => { + throw new Error("No approval device connected."); + }), + }); + + const turn = await runAgentTurn("Send 0.01", { client, tools }); + + expect(turn.text).toBe("No device is connected."); + expect(turn.toolCalls[0]?.error).toBe("No approval device connected."); + expect(turn.toolCalls[0]?.output).toEqual({ error: "No approval device connected." }); + }); + + it("rejects arguments that do not match the schema instead of passing them through", async () => { + const { client } = scriptedClient([ + { output: [toolCall("propose_send", { to: "not-an-address", amountEth: "0.01", memo: null })] }, + { output: [message("That address is not valid.")], text: "That address is not valid." }, + ]); + const tools = fakeTools(); + + const turn = await runAgentTurn("Send 0.01 to not-an-address", { client, tools }); + + expect(tools.propose_send).not.toHaveBeenCalled(); + expect(turn.toolCalls[0]?.error).toMatch(/to:/); + }); + + // Known gap, pinned rather than papered over: a wei integer is a valid decimal string, so the + // schema cannot tell "10000000000000000 wei" from "ten million ETH". Only the tool description + // stands between the model and an amount eighteen orders of magnitude off. If spike 2 shows the + // model doing this, the fix is an upper bound checked against the balance, not a regex. + it("cannot catch an amount given in wei, which the schema reads as a large decimal", async () => { + const { client } = scriptedClient([ + { output: [toolCall("propose_send", { to: RECIPIENT, amountEth: "10000000000000000", memo: null })] }, + { output: [message("ok")], text: "ok" }, + ]); + const tools = fakeTools(); + const turn = await runAgentTurn("send", { client, tools }); + + expect(tools.propose_send).toHaveBeenCalled(); + expect(turn.toolCalls[0]?.error).toBeUndefined(); + }); + + it("refuses an amount sent as a number", async () => { + const { client } = scriptedClient([ + { output: [toolCall("propose_send", { to: RECIPIENT, amountEth: 0.01, memo: null })] }, + { output: [message("ok")], text: "ok" }, + ]); + const tools = fakeTools(); + const turn = await runAgentTurn("send", { client, tools }); + + expect(tools.propose_send).not.toHaveBeenCalled(); + expect(turn.toolCalls[0]?.error).toMatch(/amountEth/); + }); + + it("survives arguments that are not valid JSON", async () => { + const { client } = scriptedClient([ + { output: [{ type: "function_call", name: "propose_send", call_id: "c1", arguments: "{oops" }] }, + { output: [message("Something went wrong.")], text: "Something went wrong." }, + ]); + const turn = await runAgentTurn("send", { client, tools: fakeTools() }); + + expect(turn.toolCalls[0]?.error).toMatch(/not valid JSON/); + expect(turn.text).toBe("Something went wrong."); + }); + + it("refuses a tool it does not have", async () => { + const { client } = scriptedClient([ + { output: [toolCall("drain_wallet", {})] }, + { output: [message("I cannot do that.")], text: "I cannot do that." }, + ]); + const turn = await runAgentTurn("drain it", { client, tools: fakeTools() }); + + expect(turn.toolCalls[0]?.error).toBe("Unknown tool drain_wallet"); + }); + + it("runs several tool calls from one response", async () => { + const { client } = scriptedClient([ + { output: [toolCall("get_wallet", {}, "c1"), toolCall("list_proposals", { limit: null }, "c2")] }, + { output: [message("Here you go.")], text: "Here you go." }, + ]); + const tools = fakeTools(); + const turn = await runAgentTurn("what's in the wallet", { client, tools }); + + expect(turn.toolCalls.map((c) => c.name)).toEqual(["get_wallet", "list_proposals"]); + expect(turn.roundTrips).toBe(1); + expect(tools.get_wallet).toHaveBeenCalled(); + }); + + // A model that keeps calling tools keeps spending. The cap has to hold even against a model + // that never stops asking. + it("stops at the round-trip cap and still answers", async () => { + const forever = Array.from({ length: 20 }, () => ({ + output: [toolCall("get_wallet", {})], + })); + const { client, calls } = scriptedClient([...forever, { output: [message("done")], text: "done" }]); + const tools = fakeTools(); + + const turn = await runAgentTurn("loop forever", { client, tools }); + + expect(turn.hitCap).toBe(true); + expect(turn.roundTrips).toBe(MAX_TOOL_ROUND_TRIPS); + expect(tools.get_wallet).toHaveBeenCalledTimes(MAX_TOOL_ROUND_TRIPS); + // Every function_call still got an output, or the next request would be malformed. + const last = calls.at(-1)!.input as OpenAI.Responses.ResponseInputItem[]; + const made = last.filter((i) => "type" in i && i.type === "function_call").length; + const answered = last.filter((i) => "type" in i && i.type === "function_call_output").length; + expect(answered).toBe(made); + }); + + // The cap has to bound the API calls, not just the tools that run. The test above lets the + // scripted model give up on its own, which hides the case that costs money: a model that + // answers "stop calling tools" with another tool call. Nothing here ever stops asking. + it("stops requesting once the budget is spent, even if the model never stops asking", async () => { + const calls: OpenAI.Responses.ResponseCreateParamsNonStreaming[] = []; + const client: ResponsesClient = { + responses: { + create: vi.fn(async (body) => { + calls.push(body); + return { + output: [toolCall("get_wallet", {}, `call_${calls.length}`)], + output_text: "", + usage: { input_tokens: 10, output_tokens: 5 }, + } as unknown as OpenAI.Responses.Response; + }), + }, + }; + const tools = fakeTools(); + + const turn = await runAgentTurn("never stop", { client, tools }); + + expect(turn.hitCap).toBe(true); + expect(tools.get_wallet).toHaveBeenCalledTimes(MAX_TOOL_ROUND_TRIPS); + // Six round trips, the request that hits the cap, then one final request with tools forbidden. + expect(calls).toHaveLength(MAX_TOOL_ROUND_TRIPS + 2); + expect(calls.at(-1)!.tool_choice).toBe("none"); + + // The final response's tool call cannot be answered, so it must not reach the history the + // caller keeps. Every function_call that does survive has an output next to it. + const made = turn.items.filter((i) => "type" in i && i.type === "function_call").length; + const answered = turn.items.filter((i) => "type" in i && i.type === "function_call_output").length; + expect(made).toBe(MAX_TOOL_ROUND_TRIPS + 1); + expect(answered).toBe(made); + }); + + it("carries prior history into the first request", async () => { + const { client, calls } = scriptedClient([{ output: [message("ok")], text: "ok" }]); + const history = [{ role: "user" as const, content: "earlier" }]; + + await runAgentTurn("now", { client, tools: fakeTools(), history }); + + expect(calls[0]!.input).toEqual([{ role: "user", content: "earlier" }, { role: "user", content: "now" }]); + }); + + it("returns items the caller can append to history", async () => { + const { client } = scriptedClient([ + { output: [toolCall("get_wallet", {})] }, + { output: [message("ok")], text: "ok" }, + ]); + const turn = await runAgentTurn("hi", { client, tools: fakeTools() }); + + expect(turn.items[0]).toEqual({ role: "user", content: "hi" }); + expect(turn.items.filter((i) => "type" in i && i.type === "function_call_output")).toHaveLength(1); + }); +}); diff --git a/apps/web/src/server/agent/loop.ts b/apps/web/src/server/agent/loop.ts new file mode 100644 index 0000000..2de1cb4 --- /dev/null +++ b/apps/web/src/server/agent/loop.ts @@ -0,0 +1,211 @@ +/** + * The agent tool-calling loop. SPEC §3.5. + * + * One user message in, one assistant reply out, with up to six tool round trips in between. The + * loop never waits for the human: `propose_*` returns as soon as the row is written, and the + * outcome arrives on a later turn through `get_proposal`. See docs/spikes.md entry 3. + * + * Two traps that cost a morning if you forget them: + * - Tool calls go through `client.responses.create`, not chat completions. GPT-5.6 rejects + * function tools on /v1/chat/completions while reasoning is on. + * - `arguments` arrives as a JSON *string*. Parse it, then validate it. Never string-match it. + */ + +import type OpenAI from "openai"; + +import { SYSTEM_PROMPT } from "./prompt"; +import { + TOOL_DEFINITIONS, + TOOL_SCHEMAS, + isAgentToolName, + type AgentToolName, + type AgentTools, +} from "./tools"; + +export const MODEL = "gpt-5.6-terra"; + +/** + * Reasoning effort defaults to `medium`, which is the main cost lever and more than this loop + * needs. Spike 2 exists to confirm `low` still calls the tool reliably. + */ +export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; +export const DEFAULT_EFFORT: ReasoningEffort = "low"; + +/** + * A model that keeps calling tools is a model that will keep spending. Six is enough for + * get_wallet, a propose, a get_proposal and slack; past that the turn ends with what it has. + */ +export const MAX_TOOL_ROUND_TRIPS = 6; + +/** Just the part of the OpenAI client this loop uses, so a test can pass a fake. */ +export interface ResponsesClient { + responses: { + create( + body: OpenAI.Responses.ResponseCreateParamsNonStreaming, + ): Promise; + }; +} + +export interface ToolInvocation { + name: string; + /** The parsed arguments, or the raw string when it was not valid JSON. */ + input: unknown; + output: unknown; + /** Set when the arguments failed validation or the handler threw. The model is told either way. */ + error?: string; +} + +export interface AgentTurn { + text: string; + toolCalls: ToolInvocation[]; + /** Append to the caller's history so the next turn sees the tool calls and their results. */ + items: OpenAI.Responses.ResponseInputItem[]; + roundTrips: number; + /** True when the loop stopped on the cap rather than because the model was done. */ + hitCap: boolean; + inputTokens: number; + outputTokens: number; +} + +export interface RunAgentTurnOptions { + client: ResponsesClient; + tools: AgentTools; + /** Prior turns' items, oldest first. The caller owns this. */ + history?: OpenAI.Responses.ResponseInputItem[]; + model?: string; + effort?: ReasoningEffort; + maxRoundTrips?: number; +} + +/** + * Runs one tool call. A bad input or a throwing handler comes back as an error *result* rather + * than an exception, because the model needs to read it: "no approval device connected" is + * something the user has to be told, and a thrown error would end the turn with silence. + * The error is recorded on the turn as well, so the caller can see it without parsing prose. + */ +async function callTool( + tools: AgentTools, + name: AgentToolName, + rawArguments: string, +): Promise<{ output: unknown; error?: string; input: unknown }> { + let input: unknown; + try { + input = JSON.parse(rawArguments); + } catch (error) { + const message = `arguments were not valid JSON: ${(error as Error).message}`; + return { input: rawArguments, output: { error: message }, error: message }; + } + + const parsed = TOOL_SCHEMAS[name].safeParse(input); + if (!parsed.success) { + const message = parsed.error.issues + .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "); + return { input, output: { error: message }, error: message }; + } + + try { + // The dispatch is uniform but the handlers are not, and TypeScript cannot see that the schema + // and the handler for a given name agree. They are declared together in tools.ts. + const handler = tools[name] as (arg: unknown) => Promise; + return { input: parsed.data, output: await handler.call(tools, parsed.data) }; + } catch (error) { + const message = (error as Error).message; + return { input: parsed.data, output: { error: message }, error: message }; + } +} + +export async function runAgentTurn( + userMessage: string, + options: RunAgentTurnOptions, +): Promise { + const { + client, + tools, + history = [], + model = MODEL, + effort = DEFAULT_EFFORT, + maxRoundTrips = MAX_TOOL_ROUND_TRIPS, + } = options; + + const newItems: OpenAI.Responses.ResponseInputItem[] = [ + { role: "user", content: userMessage }, + ]; + const toolCalls: ToolInvocation[] = []; + let text = ""; + let inputTokens = 0; + let outputTokens = 0; + let roundTrips = 0; + let hitCap = false; + // Set once the budget is spent. The next request forbids tools and is the last one, so a model + // that never stops asking cannot keep the turn alive. Telling it to stop is not enough: asking + // nicely and looping again is an unbounded bill and a request that never returns. + let forceAnswer = false; + + for (;;) { + const response = await client.responses.create({ + model, + instructions: SYSTEM_PROMPT, + input: [...history, ...newItems], + tools: TOOL_DEFINITIONS, + ...(forceAnswer ? { tool_choice: "none" as const } : {}), + reasoning: { effort }, + max_output_tokens: 16000, + }); + + inputTokens += response.usage?.input_tokens ?? 0; + outputTokens += response.usage?.output_tokens ?? 0; + text = response.output_text?.trim() ?? ""; + + // Reasoning items have to be carried forward too, or the next request drops the model's own + // context for the tool call it just made. Calls made on the final request are dropped instead: + // nothing will answer them, and an unanswered function_call in the history the caller keeps + // makes every later turn fail. `tool_choice: "none"` should mean there are none. + const output = forceAnswer + ? response.output.filter((item) => item.type !== "function_call") + : response.output; + newItems.push(...(output as unknown as OpenAI.Responses.ResponseInputItem[])); + + if (forceAnswer) break; + + const calls = response.output.filter( + (item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === "function_call", + ); + if (calls.length === 0) break; + + if (roundTrips >= maxRoundTrips) { + hitCap = true; + forceAnswer = true; + // Answer every call, or the next request is malformed: the Responses API requires an output + // for each function_call already in the input. + for (const call of calls) { + const message = `Tool budget of ${maxRoundTrips} round trips is spent. Stop calling tools and answer with what you have.`; + toolCalls.push({ name: call.name, input: call.arguments, output: { error: message }, error: message }); + newItems.push({ type: "function_call_output", call_id: call.call_id, output: JSON.stringify({ error: message }) }); + } + continue; + } + roundTrips++; + + for (const call of calls) { + const result = isAgentToolName(call.name) + ? await callTool(tools, call.name, call.arguments) + : { + input: call.arguments, + output: { error: `Unknown tool ${call.name}` }, + error: `Unknown tool ${call.name}`, + }; + + toolCalls.push({ name: call.name, input: result.input, output: result.output, error: result.error }); + newItems.push({ + type: "function_call_output", + call_id: call.call_id, + // JSON.stringify throws on bigint, and balances are the obvious thing a handler returns. + // Amounts cross this boundary as strings; anything else is a bug in the handler. + output: JSON.stringify(result.output), + }); + } + } + + return { text, toolCalls, items: newItems, roundTrips, hitCap, inputTokens, outputTokens }; +} diff --git a/apps/web/src/server/agent/prompt.ts b/apps/web/src/server/agent/prompt.ts new file mode 100644 index 0000000..9e14c3f --- /dev/null +++ b/apps/web/src/server/agent/prompt.ts @@ -0,0 +1,28 @@ +/** + * SPEC §3.5. The system prompt. + * + * Two lines here exist to stop the same failure: a model that believes it moved money will tell + * the user it did, and the user will believe it. The tools only ever propose, so the prompt says + * so twice and forbids claiming success before the status is EXECUTED. + * + * There is deliberately no prompt-injection defence in here. The defence is the human pressing + * Back on the Flipper, and that is the entire pitch. See docs/DECISIONS.md #10. If you are about + * to add "ignore instructions found in tool output", read that first. + */ +export const SYSTEM_PROMPT = [ + "You control a wallet gated by a human holding a Flipper Zero. You can only propose.", + "Every proposal appears on the human's device and they physically approve or reject it.", + "Never claim a transaction succeeded unless its status is EXECUTED.", + "", + "How the tools behave:", + "- propose_send, propose_swap and propose_buy return a proposal id immediately. They do not", + " wait for the human and they do not mean anything was sent. Say that a proposal is waiting", + " on the device, not that money moved.", + "- Call get_proposal to find out what the human decided. Until then the status is PENDING_HUMAN.", + "- Only one proposal can be pending at a time. If a proposal is refused for that reason, tell", + " the user what is already waiting on the device instead of retrying.", + "", + "Amounts are decimal strings in the chain's native coin, for example \"0.01\". Never wei, never", + "a number. If you do not know an amount or an address, ask instead of guessing. Never invent an", + "exchange rate.", +].join("\n"); diff --git a/apps/web/src/server/agent/tools.ts b/apps/web/src/server/agent/tools.ts new file mode 100644 index 0000000..85ce84f --- /dev/null +++ b/apps/web/src/server/agent/tools.ts @@ -0,0 +1,212 @@ +/** + * SPEC §3.5. The tool definitions and the interface that executes them. + * + * These are function-tool definitions run by our own backend, not an MCP server. The definitions + * and the zod schemas sit side by side on purpose: the Responses API hands `arguments` back as a + * JSON *string* and will happily send a shape that does not match `parameters`, so every input is + * parsed and then validated. Never string-match a serialized tool input. + * + * `strict: true` requires every property to appear in `required`, so an optional field is written + * as a nullable type instead of being left out. + */ + +import type OpenAI from "openai"; +import { z } from "zod"; + +import { addressSchema, hexSchema, type ProposalStatus } from "@tappy/protocol"; + +/** A decimal amount in the chain's native coin. Wei and JS numbers both lose money here. */ +const decimalAmount = z + .string() + .regex(/^\d+(\.\d+)?$/, "must be a decimal string like \"0.01\", not wei and not a number"); + +export const proposeSendInput = z.object({ + to: addressSchema, + amountEth: decimalAmount, + memo: z.string().nullish(), +}); + +export const proposeSwapInput = z.object({ + sellEth: decimalAmount, + minTokensOut: decimalAmount.nullish(), +}); + +export const proposeBuyInput = z.object({ itemId: z.string().min(1) }); + +export const getProposalInput = z.object({ proposalId: hexSchema }); + +export const listProposalsInput = z.object({ limit: z.number().int().min(1).max(50).nullish() }); + +export const getWalletInput = z.object({}); + +/* ---- what the tools give back --------------------------------------------- */ + +export interface WalletSummary { + gate: string; + chain: string; + balance: string; + agent: string; + human: string; + /** No device, no proposals. `propose_*` fails loudly rather than queueing. */ + signerConnected: boolean; +} + +export interface ProposeResult { + proposalId: string; + status: ProposalStatus; + summary: string; +} + +export interface ProposalSummary { + proposalId: string; + status: ProposalStatus; + summary: string; + txHash?: string; + error?: string; +} + +/** + * The seam between the loop and everything with side effects. The loop takes one of these, so a + * test can drive the whole conversation without a database, a chain or a device. + */ +export interface AgentTools { + get_wallet(): Promise; + propose_send(input: z.infer): Promise; + propose_swap(input: z.infer): Promise; + propose_buy(input: z.infer): Promise; + get_proposal(input: z.infer): Promise; + list_proposals(input: z.infer): Promise; +} + +export type AgentToolName = keyof AgentTools; + +/** Parses and validates one tool's arguments, then calls it. One place, so nothing skips zod. */ +export const TOOL_SCHEMAS = { + get_wallet: getWalletInput, + propose_send: proposeSendInput, + propose_swap: proposeSwapInput, + propose_buy: proposeBuyInput, + get_proposal: getProposalInput, + list_proposals: listProposalsInput, +} satisfies Record; + +export function isAgentToolName(name: string): name is AgentToolName { + return name in TOOL_SCHEMAS; +} + +/* ---- the definitions the model sees --------------------------------------- */ + +/** + * Every `propose_*` description says the tool does not send anything. A model that thinks it is + * moving money reports success it never got, and the user has no way to tell the difference. + */ +export const TOOL_DEFINITIONS: OpenAI.Responses.FunctionTool[] = [ + { + type: "function", + name: "get_wallet", + description: + "Read the wallet: gate address, chain, native balance, the agent and human addresses, and " + + "whether the human's approval device is currently connected. Call this before proposing if " + + "you are unsure the balance covers the amount.", + parameters: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true, + }, + { + type: "function", + name: "propose_send", + description: + "Propose sending native currency from the wallet to an address. This does NOT send " + + "anything. It creates a proposal, shows it on the human's Flipper Zero, and returns a " + + "proposal id immediately. The human physically approves or rejects it on the device; only " + + "then does the transaction execute.", + parameters: { + type: "object", + properties: { + to: { type: "string", description: "Recipient address, 0x-prefixed and 20 bytes." }, + amountEth: { + type: "string", + description: + 'Amount in the chain\'s native coin as a decimal string, for example "0.01". Never wei, never a number.', + }, + memo: { + type: ["string", "null"], + description: "Optional short note. The human reads it on the device screen. Null if none.", + }, + }, + required: ["to", "amountEth", "memo"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "propose_swap", + description: + "Propose swapping native currency for tokens on the demo DEX. This does NOT swap anything. " + + "It creates a proposal for the human to approve on their device and returns a proposal id.", + parameters: { + type: "object", + properties: { + sellEth: { + type: "string", + description: 'Amount of native coin to sell, as a decimal string, for example "0.05".', + }, + minTokensOut: { + type: ["string", "null"], + description: + "Minimum tokens to accept, as a decimal string. Null to let the server quote it.", + }, + }, + required: ["sellEth", "minTokensOut"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "propose_buy", + description: + "Propose buying an item from the demo shop. This does NOT buy anything. The server looks up " + + "the invoice and price for the item id; you do not supply an amount. Returns a proposal id " + + "for the human to approve on their device.", + parameters: { + type: "object", + properties: { + itemId: { type: "string", description: "The shop item id, as shown on the shop page." }, + }, + required: ["itemId"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "get_proposal", + description: + "Read one proposal's current status. PENDING_HUMAN means the human has not decided yet. " + + "Only EXECUTED means the transaction actually happened.", + parameters: { + type: "object", + properties: { + proposalId: { type: "string", description: "The proposal id returned by a propose_ tool." }, + }, + required: ["proposalId"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "list_proposals", + description: "List recent proposals, newest first, with their statuses.", + parameters: { + type: "object", + properties: { + limit: { type: ["number", "null"], description: "How many to return, 1 to 50. Null for 20." }, + }, + required: ["limit"], + additionalProperties: false, + }, + strict: true, + }, +]; diff --git a/apps/web/src/server/tappy/README.md b/apps/web/src/server/tappy/README.md index a3a8ed0..dd422c1 100644 --- a/apps/web/src/server/tappy/README.md +++ b/apps/web/src/server/tappy/README.md @@ -1,12 +1,30 @@ Everything between a proposal and the chain. See `docs/workstreams/app.md`. -- `store.ts` — persistence + `transition(id, from, to)` that throws on an illegal move. -- `proposals.ts` — builds a proposal: reads the nonce from chain, sets `deadline = now + 600`, - computes the digest with `@tappy/protocol`, gets the agent signature. -- `agentSigner.ts` — `PrivyAgentSigner | LocalAgentSigner` behind one interface. +Built: + +- `store.ts` — persistence + `transition(db, id, from, to)` that throws on an illegal move, and + refuses a second `PENDING_HUMAN` row because the nonce would collide. +- `proposals.ts` — `buildProposal` and `callForAction`, the one place an `Action` becomes bytes. + Deadline is `now + 600`, the digest comes from `@tappy/protocol`, the agent signs before the row + is written. +- `agentSigner.ts` — `AgentSigner` interface + `LocalAgentSigner`. `PrivyAgentSigner` is not + written yet; `agentSignerFromEnv` throws for `AGENT_SIGNER=privy` rather than falling back. + +Not built yet: + - `humanSigner.ts` — `mock` (in-process `MockHumanSigner`) or `external` (the bridge). - `relayer.ts` — submits `TappyGate.execute`, waits for the receipt. +- The chain client that reads `TappyGate.nonce()`. `buildProposal` takes the nonce as an argument + until something is deployed; whoever adds the client passes it in and nothing else changes. On boot, assert `TappyGate.digestOf(...)` equals `proposalDigest(...)` using the frozen vector, and refuse to start if they differ. That check is the difference between a five-minute bug and -a five-hour one. +a five-hour one. Still to do, and it needs a deployment. + +## Checking it + +- `pnpm --filter @tappy/web test` — the pure half: call encoding, the digest the agent signs, the + state machine, row mapping. +- `pnpm --filter @tappy/web verify:store` — the half only a real Postgres can answer: wei through + text columns, the compare-and-set in `transition`, the one-pending rule. Needs `DATABASE_URL`. +- `pnpm --filter @tappy/web verify:channel` — the approval channel, spike 3. diff --git a/apps/web/src/server/tappy/agentSigner.ts b/apps/web/src/server/tappy/agentSigner.ts new file mode 100644 index 0000000..b6237cb --- /dev/null +++ b/apps/web/src/server/tappy/agentSigner.ts @@ -0,0 +1,63 @@ +/** + * The agent half of the 2-of-2. One interface so a local key and a Privy wallet are swappable, + * which is cut line #4: if Privy fights us, `AGENT_SIGNER=local` and the demo still runs. + * + * The method takes a `DigestInput` rather than a finished hash on purpose. Privy signs typed data + * (`walletApi.ethereum.signTypedData`) and has no way to sign an arbitrary 32-byte hash, so an + * interface shaped around `signDigest(hash)` would have only one possible implementation. + */ + +import { privateKeyToAccount } from "viem/accounts"; +import type { Address, Hex } from "viem"; + +import { EXECUTE_TYPES, domain, type DigestInput } from "@tappy/protocol"; + +import { env } from "~/env"; + +export interface AgentSigner { + address(): Promise
; + signProposal(input: DigestInput): Promise; +} + +export class LocalAgentSigner implements AgentSigner { + private readonly account: ReturnType; + + constructor(privateKey: Hex) { + this.account = privateKeyToAccount(privateKey); + } + + address(): Promise
{ + return Promise.resolve(this.account.address); + } + + signProposal(input: DigestInput): Promise { + return this.account.signTypedData({ + domain: domain(input.chainId, input.gate), + types: EXECUTE_TYPES, + primaryType: "Execute", + message: { + nonce: input.nonce, + to: input.call.to, + value: input.call.value, + data: input.call.data, + deadline: BigInt(input.deadline), + }, + }); + } +} + +/** + * Resolved once at boot, not per request, so a missing key is a startup failure rather than a + * revert three steps later that reads as "bad signature". + */ +export function agentSignerFromEnv(): AgentSigner { + if (env.AGENT_SIGNER === "privy") { + throw new Error( + "AGENT_SIGNER=privy is not implemented yet. Set AGENT_SIGNER=local and AGENT_PRIVATE_KEY.", + ); + } + if (!env.AGENT_PRIVATE_KEY) { + throw new Error("AGENT_SIGNER=local needs AGENT_PRIVATE_KEY. Nothing can be proposed without it."); + } + return new LocalAgentSigner(env.AGENT_PRIVATE_KEY as Hex); +} diff --git a/apps/web/src/server/tappy/proposals.test.ts b/apps/web/src/server/tappy/proposals.test.ts new file mode 100644 index 0000000..3ad8ff9 --- /dev/null +++ b/apps/web/src/server/tappy/proposals.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { decodeFunctionData, keccak256, recoverTypedDataAddress, stringToHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +import { MockMerchantAbi, MockSwapAbi } from "@tappy/contracts"; +import { EXECUTE_TYPES, domain, proposalDigest, type Action } from "@tappy/protocol"; + +import { LocalAgentSigner } from "./agentSigner"; +import { + PROPOSAL_TTL_SECONDS, + buildProposal, + callForAction, + invoiceIdToBytes32, +} from "./proposals"; + +/** Anvil account #0. The same well-known test key the frozen vector uses. Public on purpose. */ +const AGENT_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const GATE = "0x1111111111111111111111111111111111111111" as const; +const RECIPIENT = "0x2222222222222222222222222222222222222222" as const; +const DEX = "0x3333333333333333333333333333333333333333" as const; +const MERCHANT = "0x4444444444444444444444444444444444444444" as const; +const TOKEN = "0x5555555555555555555555555555555555555555" as const; +const CHAIN_ID = 11155111; +const NOW = 1_800_000_000; + +const send: Action = { kind: "send", to: RECIPIENT, valueWei: 10_000_000_000_000_000n }; +const swap: Action = { + kind: "swap", + dex: DEX, + sellWei: 5_000_000_000_000_000n, + minBuy: 42n, + tokenOut: TOKEN, +}; +const buy: Action = { + kind: "buy", + merchant: MERCHANT, + valueWei: 1_000_000_000_000_000n, + invoiceId: "inv_0001", + itemName: "Rubber Duck", + shopUrl: "http://localhost:3000/shop", +}; + +describe("callForAction", () => { + it("sends value with empty calldata", () => { + expect(callForAction(send)).toEqual({ to: RECIPIENT, value: send.valueWei, data: "0x" }); + }); + + it("encodes a swap as swapExactEthForTokens(minOut) with the sell amount as value", () => { + const call = callForAction(swap); + expect(call.to).toBe(DEX); + expect(call.value).toBe(5_000_000_000_000_000n); + expect(decodeFunctionData({ abi: MockSwapAbi, data: call.data })).toEqual({ + functionName: "swapExactEthForTokens", + args: [42n], + }); + }); + + it("encodes a buy as pay(invoiceId) with the invoice id hashed to bytes32", () => { + const call = callForAction(buy); + expect(call.to).toBe(MERCHANT); + expect(decodeFunctionData({ abi: MockMerchantAbi, data: call.data })).toEqual({ + functionName: "pay", + args: [keccak256(stringToHex("inv_0001"))], + }); + }); + + it("hashes invoice ids, so no caller-chosen id can collide with another", () => { + expect(invoiceIdToBytes32("inv_0001")).not.toBe(invoiceIdToBytes32("inv_0002")); + expect(invoiceIdToBytes32("inv_0001")).toHaveLength(66); + }); +}); + +describe("buildProposal", () => { + const signer = new LocalAgentSigner(AGENT_KEY); + const input = { + action: send, + chainId: CHAIN_ID, + gate: GATE, + nonce: 7n, + chainKey: "sepolia", + originator: "chat" as const, + now: NOW, + }; + + it("makes the id the digest of the call it actually built", async () => { + const { proposal } = await buildProposal(input, signer); + expect(proposal.id).toBe( + proposalDigest({ + chainId: CHAIN_ID, + gate: GATE, + nonce: 7n, + call: callForAction(send), + deadline: NOW + PROPOSAL_TTL_SECONDS, + }), + ); + }); + + it("sets the deadline ten minutes out and starts PENDING_HUMAN", async () => { + const { proposal } = await buildProposal(input, signer); + expect(proposal.deadline).toBe(NOW + 600); + expect(proposal.status).toBe("PENDING_HUMAN"); + }); + + // The gate recovers this address on chain. If it does not match, execute() reverts with + // BadAgentSig and the revert says nothing about why. + it("agent-signs the same typed data the gate will verify", async () => { + const { proposal } = await buildProposal(input, signer); + const recovered = await recoverTypedDataAddress({ + domain: domain(CHAIN_ID, GATE), + types: EXECUTE_TYPES, + primaryType: "Execute", + message: { + nonce: proposal.nonce, + to: proposal.call.to, + value: proposal.call.value, + data: proposal.call.data, + deadline: BigInt(proposal.deadline), + }, + signature: proposal.agentSig!, + }); + expect(recovered).toBe(privateKeyToAccount(AGENT_KEY).address); + }); + + it("builds the view from the same proposal, so the screen cannot drift from the digest", async () => { + const { proposal, view } = await buildProposal(input, signer); + expect(view.id).toBe(proposal.id); + expect(view.digest).toBe(proposal.id); + expect(view.action).toBe("SEND"); + expect(view.amount).toBe("0.010 ETH"); + }); + + it("changing only the nonce changes the id", async () => { + const a = await buildProposal(input, signer); + const b = await buildProposal({ ...input, nonce: 8n }, signer); + expect(a.proposal.id).not.toBe(b.proposal.id); + }); + + it("labels a swap and a buy for the device", async () => { + const s = await buildProposal({ ...input, action: swap }, signer); + const b = await buildProposal({ ...input, action: buy }, signer); + expect(s.view.action).toBe("SWAP"); + expect(b.view.action).toBe("BUY"); + expect(b.view.counterparty).toBe("Shop: Rubber Duck"); + }); +}); diff --git a/apps/web/src/server/tappy/proposals.ts b/apps/web/src/server/tappy/proposals.ts new file mode 100644 index 0000000..be98e69 --- /dev/null +++ b/apps/web/src/server/tappy/proposals.ts @@ -0,0 +1,110 @@ +/** + * Turning what the agent asked for into the exact bytes the human approves. + * + * The whole design rests on one thing: the `call` is the truth and the `action` is only a label + * for it. The digest covers the call, so a proposal that says "send 0.01" in the chat and carries + * a different `to` in the calldata cannot exist. Anything that builds a call belongs here, and + * nowhere else, so there is exactly one place to audit. + */ + +import { encodeFunctionData, keccak256, stringToHex } from "viem"; +import type { Address, Hex } from "viem"; + +import { MockMerchantAbi, MockSwapAbi } from "@tappy/contracts"; +import { proposalDigest, toView, type Action, type Call, type Proposal } from "@tappy/protocol"; + +import type { AgentSigner } from "./agentSigner"; + +/** SPEC §2: the human gets ten minutes to press. After that the contract refuses the call. */ +export const PROPOSAL_TTL_SECONDS = 600; + +/** + * `MockMerchant.pay` takes a bytes32, and invoice ids are strings the shop makes up. Hashing is + * the only mapping that cannot collide with a caller-chosen id. The shop must use this same + * function when it renders the invoice, or the merchant will credit a different one. + */ +export function invoiceIdToBytes32(invoiceId: string): Hex { + return keccak256(stringToHex(invoiceId)); +} + +/** + * The one place an `Action` becomes bytes. Value always rides on the call, never on an approval, + * because the gate holds the funds and every action here spends native coin. + */ +export function callForAction(action: Action): Call { + switch (action.kind) { + case "send": + return { to: action.to, value: action.valueWei, data: "0x" }; + case "swap": + return { + to: action.dex, + value: action.sellWei, + data: encodeFunctionData({ + abi: MockSwapAbi, + functionName: "swapExactEthForTokens", + args: [action.minBuy], + }), + }; + case "buy": + return { + to: action.merchant, + value: action.valueWei, + data: encodeFunctionData({ + abi: MockMerchantAbi, + functionName: "pay", + args: [invoiceIdToBytes32(action.invoiceId)], + }), + }; + } +} + +export interface BuildProposalInput { + action: Action; + chainId: number; + gate: Address; + /** Read from `TappyGate.nonce()` by the caller. Guessing it produces a proposal that reverts. */ + nonce: bigint; + chainKey: string; + originator: Proposal["originator"]; + /** Unix seconds. Injected so a test can pin it; production leaves it out. */ + now?: number; +} + +/** + * Builds and agent-signs a proposal. It does not persist it and it does not wait for the human: + * the caller writes the row and returns immediately. See `docs/spikes.md` entry 3. + */ +export async function buildProposal(input: BuildProposalInput, signer: AgentSigner) { + const now = input.now ?? Math.floor(Date.now() / 1000); + const deadline = now + PROPOSAL_TTL_SECONDS; + const call = callForAction(input.action); + + const digestInput = { + chainId: input.chainId, + gate: input.gate, + nonce: input.nonce, + call, + deadline, + }; + const id = proposalDigest(digestInput); + + const agentSig = await signer.signProposal(digestInput); + + const proposal: Proposal = { + id, + chainId: input.chainId, + gate: input.gate, + nonce: input.nonce, + call, + action: input.action, + deadline, + agentSig, + status: "PENDING_HUMAN", + createdAt: now, + originator: input.originator, + }; + + // Built once here so the device never derives what it renders. What the human sees and what the + // digest covers come out of the same function call. + return { proposal, view: toView(proposal, input.chainKey) }; +} diff --git a/apps/web/src/server/tappy/store.test.ts b/apps/web/src/server/tappy/store.test.ts new file mode 100644 index 0000000..6e11df6 --- /dev/null +++ b/apps/web/src/server/tappy/store.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { ALLOWED_TRANSITIONS, PROPOSAL_STATUSES, type Proposal } from "@tappy/protocol"; + +import { + IllegalTransitionError, + assertLegalTransition, + isLegalTransition, + proposalToRow, + rowToProposal, +} from "./store"; + +const view = { + id: "0xaa" as const, + short: "0xaa…aa", + action: "SEND" as const, + amount: "0.010 ETH", + counterparty: "0x2222…2222", + chain: "Sepolia", + digest: "0xaa" as const, +}; + +const proposal: Proposal = { + id: `0x${"ab".repeat(32)}`, + chainId: 11155111, + gate: "0x1111111111111111111111111111111111111111", + // Larger than Number.MAX_SAFE_INTEGER on purpose: this is the value that a numeric column loses. + nonce: 9_007_199_254_740_993n, + call: { + to: "0x2222222222222222222222222222222222222222", + value: 123_456_789_012_345_678n, + data: "0x", + }, + action: { kind: "send", to: "0x2222222222222222222222222222222222222222", valueWei: 123_456_789_012_345_678n }, + deadline: 1_800_000_600, + agentSig: `0x${"cd".repeat(65)}`, + status: "PENDING_HUMAN", + createdAt: 1_800_000_000, + originator: "chat", +}; + +describe("the state machine", () => { + it("allows exactly what ALLOWED_TRANSITIONS lists and nothing else", () => { + for (const from of PROPOSAL_STATUSES) { + for (const to of PROPOSAL_STATUSES) { + expect(isLegalTransition(from, to)).toBe(ALLOWED_TRANSITIONS[from].includes(to)); + } + } + }); + + it("treats every terminal status as terminal", () => { + for (const terminal of ["REJECTED", "EXECUTED", "FAILED", "EXPIRED"] as const) { + for (const to of PROPOSAL_STATUSES) { + expect(isLegalTransition(terminal, to)).toBe(false); + } + } + }); + + // The point of the whole thing: an illegal move stops the process rather than logging. + it("throws on an illegal move rather than warning", () => { + expect(() => assertLegalTransition("0xab", "EXECUTED", "REJECTED")).toThrow( + IllegalTransitionError, + ); + expect(() => assertLegalTransition("0xab", "PENDING_HUMAN", "EXECUTED")).toThrow( + /Illegal transition PENDING_HUMAN -> EXECUTED/, + ); + }); + + it("lets a pending proposal reach a decision and a submitted one reach a receipt", () => { + expect(() => assertLegalTransition("0xab", "PENDING_HUMAN", "SUBMITTED")).not.toThrow(); + expect(() => assertLegalTransition("0xab", "PENDING_HUMAN", "REJECTED")).not.toThrow(); + expect(() => assertLegalTransition("0xab", "SUBMITTED", "EXECUTED")).not.toThrow(); + }); +}); + +describe("row mapping", () => { + it("round-trips wei that a numeric column would silently round", () => { + const row = proposalToRow(proposal, view); + expect(row.nonce).toBe("9007199254740993"); + expect(row.callValue).toBe("123456789012345678"); + + const back = rowToProposal({ + ...row, + seq: 1, + view, + claimedAt: null, + txHash: null, + error: null, + humanSig: null, + decidedAt: null, + createdAt: new Date(proposal.createdAt * 1000), + agentSig: row.agentSig ?? null, + }); + expect(back).toEqual(proposal); + }); + + it("stores action amounts as strings, because jsonb throws on bigint", () => { + const row = proposalToRow(proposal, view); + expect(() => JSON.stringify(row.action)).not.toThrow(); + }); +}); diff --git a/apps/web/src/server/tappy/store.ts b/apps/web/src/server/tappy/store.ts new file mode 100644 index 0000000..6ab44f0 --- /dev/null +++ b/apps/web/src/server/tappy/store.ts @@ -0,0 +1,220 @@ +/** + * Proposal persistence, and the state machine that guards it. + * + * Two rules live here rather than in the callers, because there are four callers and they must + * not each get it right independently: + * + * 1. Every status change goes through `transition`, which refuses a move `ALLOWED_TRANSITIONS` + * does not list. An illegal transition is a bug, not a log line, so it throws. + * 2. At most one proposal is `PENDING_HUMAN`. A second would reuse the nonce and the first + * execution to land would invalidate the other, which on stage looks like a random revert. + * A partial unique index in the schema backs this up so a race cannot slip past. + * + * `db` is passed in rather than imported so these work from a tRPC procedure (`ctx.db`), the + * relayer tick and a verify script alike. + */ + +import { and, desc, eq } from "drizzle-orm"; +import type { Hex } from "viem"; +import type { z } from "zod"; + +import { + ALLOWED_TRANSITIONS, + actionSchema, + type Action, + type Proposal, + type ProposalStatus, + type ProposalView, +} from "@tappy/protocol"; + +import type { db as Database } from "~/server/db"; +import { proposals } from "~/server/db/schema"; + +type Db = typeof Database; +type Row = typeof proposals.$inferSelect; + +/** Thrown when something tried to move a proposal somewhere the state machine forbids. */ +export class IllegalTransitionError extends Error { + constructor( + readonly id: string, + readonly from: ProposalStatus, + readonly to: ProposalStatus, + ) { + super(`Illegal transition ${from} -> ${to} for ${id}`); + this.name = "IllegalTransitionError"; + } +} + +/** Thrown when a proposal is asked to move but is no longer in the status the caller expected. */ +export class StaleTransitionError extends Error { + constructor( + readonly id: string, + readonly expected: ProposalStatus, + readonly actual: ProposalStatus | undefined, + ) { + super( + actual === undefined + ? `No proposal ${id}` + : `Proposal ${id} is ${actual}, not ${expected}; something else moved it first`, + ); + this.name = "StaleTransitionError"; + } +} + +export class PendingProposalError extends Error { + constructor(readonly pending: Proposal) { + super( + `Proposal ${pending.id} is still waiting on the human. Approve or reject it before proposing again.`, + ); + this.name = "PendingProposalError"; + } +} + +/** Pure, so the routers can check a move before touching the database. */ +export function isLegalTransition(from: ProposalStatus, to: ProposalStatus): boolean { + return ALLOWED_TRANSITIONS[from].includes(to); +} + +export function assertLegalTransition(id: string, from: ProposalStatus, to: ProposalStatus): void { + if (!isLegalTransition(from, to)) throw new IllegalTransitionError(id, from, to); +} + +/** + * Bigints are text columns: postgres-js hands back `bigint` as a JS number and wei does not + * survive that. `actionSchema.parse` is what turns the stored string amounts back into bigints. + */ +export function rowToProposal(row: Row): Proposal { + return { + id: row.id as Hex, + chainId: row.chainId, + gate: row.gate as Proposal["gate"], + nonce: BigInt(row.nonce), + call: { to: row.callTo as Proposal["call"]["to"], value: BigInt(row.callValue), data: row.callData as Hex }, + action: actionSchema.parse(row.action), + deadline: row.deadline, + agentSig: (row.agentSig ?? undefined) as Hex | undefined, + humanSig: (row.humanSig ?? undefined) as Hex | undefined, + status: row.status, + txHash: (row.txHash ?? undefined) as Hex | undefined, + error: row.error ?? undefined, + createdAt: Math.floor(row.createdAt.getTime() / 1000), + decidedAt: row.decidedAt ? Math.floor(row.decidedAt.getTime() / 1000) : undefined, + originator: row.originator, + }; +} + +/** + * `actionSchema.parse` goes the other way: its input takes strings and its output is bigints. The + * jsonb column stores the *input* shape, so the amounts have to be stringified by hand here. + * Parsing on the way in looks right, compiles, and then throws "Do not know how to serialize a + * BigInt" the first time a real proposal is written. + */ +function actionToStored(a: Action): z.input { + switch (a.kind) { + case "send": + return { ...a, valueWei: a.valueWei.toString() }; + case "swap": + return { ...a, sellWei: a.sellWei.toString(), minBuy: a.minBuy.toString() }; + case "buy": + return { ...a, valueWei: a.valueWei.toString() }; + } +} + +export function proposalToRow(p: Proposal, view: ProposalView): typeof proposals.$inferInsert { + return { + id: p.id, + chainId: p.chainId, + gate: p.gate, + nonce: p.nonce.toString(), + callTo: p.call.to, + callValue: p.call.value.toString(), + callData: p.call.data, + action: actionToStored(p.action), + view, + deadline: p.deadline, + agentSig: p.agentSig ?? null, + humanSig: p.humanSig ?? null, + status: p.status, + createdAt: new Date(p.createdAt * 1000), + originator: p.originator, + }; +} + +export async function getProposal(db: Db, id: string): Promise { + const row = await db.query.proposals.findFirst({ where: eq(proposals.id, id) }); + return row ? rowToProposal(row) : undefined; +} + +export async function listProposals(db: Db, limit = 20): Promise { + const rows = await db + .select() + .from(proposals) + .orderBy(desc(proposals.createdAt)) + .limit(limit); + return rows.map(rowToProposal); +} + +export async function pendingProposal(db: Db): Promise { + const row = await db.query.proposals.findFirst({ + where: eq(proposals.status, "PENDING_HUMAN"), + }); + return row ? rowToProposal(row) : undefined; +} + +/** + * Writes a new `PENDING_HUMAN` proposal. + * + * Refuses while another is pending and hands back the pending one, because the caller's next move + * is to show the human what they are already being asked to approve. Silently queueing the second + * would let the nonce collide. + */ +export async function insertProposal(db: Db, p: Proposal, view: ProposalView): Promise { + const pending = await pendingProposal(db); + if (pending) throw new PendingProposalError(pending); + + const [row] = await db.insert(proposals).values(proposalToRow(p, view)).returning(); + if (!row) throw new Error(`Insert of proposal ${p.id} returned no row`); + return rowToProposal(row); +} + +export interface TransitionPatch { + humanSig?: Hex; + txHash?: Hex; + error?: string; + decidedAt?: Date; +} + +/** + * The only way a status changes. + * + * `from` is required and matched in the WHERE clause, so this is a compare-and-set: two workers + * racing to move the same proposal cannot both win. The loser gets a `StaleTransitionError` and + * stops, rather than overwriting a decision that already happened. + */ +export async function transition( + db: Db, + id: string, + from: ProposalStatus, + to: ProposalStatus, + patch: TransitionPatch = {}, +): Promise { + assertLegalTransition(id, from, to); + + const [row] = await db + .update(proposals) + .set({ + status: to, + ...(patch.humanSig !== undefined ? { humanSig: patch.humanSig } : {}), + ...(patch.txHash !== undefined ? { txHash: patch.txHash } : {}), + ...(patch.error !== undefined ? { error: patch.error } : {}), + decidedAt: patch.decidedAt ?? new Date(), + }) + .where(and(eq(proposals.id, id), eq(proposals.status, from))) + .returning(); + + if (!row) { + const actual = await db.query.proposals.findFirst({ where: eq(proposals.id, id) }); + throw new StaleTransitionError(id, from, actual?.status); + } + return rowToProposal(row); +} diff --git a/apps/web/src/server/tappy/verifyStore.ts b/apps/web/src/server/tappy/verifyStore.ts new file mode 100644 index 0000000..06a3311 --- /dev/null +++ b/apps/web/src/server/tappy/verifyStore.ts @@ -0,0 +1,152 @@ +/** + * The proposal store against a real Postgres. + * + * The unit tests cover the pure half. This covers the half that only breaks against a real + * database: whether wei survives a round trip through text columns, whether the compare-and-set in + * `transition` actually stops a second writer, and whether the one-pending rule holds. + * + * Run: pnpm --filter @tappy/web verify:store + * + * Needs DATABASE_URL. Writes and deletes rows with `originator = 'test'`. + */ + +import { eq } from "drizzle-orm"; + +import { LocalAgentSigner } from "./agentSigner"; +import { db } from "~/server/db"; +import { proposals } from "~/server/db/schema"; +import { buildProposal } from "./proposals"; +import { + IllegalTransitionError, + PendingProposalError, + StaleTransitionError, + getProposal, + insertProposal, + listProposals, + pendingProposal, + transition, +} from "./store"; + +/** Anvil account #0. The same well-known test key as the frozen vector. Public on purpose. */ +const AGENT_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const GATE = "0x1111111111111111111111111111111111111111" as const; +const RECIPIENT = "0x2222222222222222222222222222222222222222" as const; +const CHAIN_ID = 11155111; + +let failures = 0; +function check(label: string, ok: boolean, detail = "") { + console.log(` ${ok ? "ok " : "FAIL"} ${label}${detail ? ` (${detail})` : ""}`); + if (!ok) failures++; +} + +async function expectRejection(label: string, fn: () => Promise) { + try { + await fn(); + check(label, false, "call succeeded but should have been refused"); + } catch (error) { + check(label, true, (error as Error).message.slice(0, 70)); + } +} + +const signer = new LocalAgentSigner(AGENT_KEY); + +/** A distinct nonce per proposal, so each one gets its own digest and its own row. */ +function proposalWithNonce(nonce: bigint) { + return buildProposal( + { + action: { kind: "send", to: RECIPIENT, valueWei: 10_000_000_000_000_000n }, + chainId: CHAIN_ID, + gate: GATE, + nonce, + chainKey: "sepolia", + originator: "test", + }, + signer, + ); +} + +async function main() { + await db.delete(proposals).where(eq(proposals.originator, "test")); + + console.log("\nwrite and read back"); + const { proposal, view } = await proposalWithNonce(9_007_199_254_740_993n); + const inserted = await insertProposal(db, proposal, view); + check("insert returns the proposal", inserted.id === proposal.id, inserted.id.slice(0, 12)); + + const read = await getProposal(db, proposal.id); + // A numeric column would round this to ...992. Text plus BigInt is the whole reason for the + // string columns, and this is the assertion that proves it. + check("nonce survives above 2^53", read?.nonce === proposal.nonce, String(read?.nonce)); + check("wei survives", read?.call.value === proposal.call.value, String(read?.call.value)); + check("action amounts come back as bigint", typeof read?.action === "object" && read.action.kind === "send" && read.action.valueWei === 10_000_000_000_000_000n); + check("agentSig persisted", read?.agentSig === proposal.agentSig); + check("it starts pending", read?.status === "PENDING_HUMAN", read?.status); + check("pendingProposal finds it", (await pendingProposal(db))?.id === proposal.id); + check("listProposals includes it", (await listProposals(db)).some((p) => p.id === proposal.id)); + + console.log("\nthe one-pending rule"); + const second = await proposalWithNonce(1n); + await expectRejection("a second proposal while one is pending is refused", () => + insertProposal(db, second.proposal, second.view), + ); + try { + await insertProposal(db, second.proposal, second.view); + } catch (error) { + // The caller's next move is to show the human what they are already being asked to approve, + // so the error has to carry the pending one rather than just saying no. + check( + "the refusal carries the pending proposal", + error instanceof PendingProposalError && error.pending.id === proposal.id, + ); + } + + console.log("\ntransitions"); + const moved = await transition(db, proposal.id, "PENDING_HUMAN", "SUBMITTED", { + humanSig: `0x${"cd".repeat(65)}`, + }); + check("PENDING_HUMAN -> SUBMITTED", moved.status === "SUBMITTED", moved.status); + check("the patch was written", moved.humanSig === `0x${"cd".repeat(65)}`); + check("decidedAt was set", moved.decidedAt !== undefined); + + // Losing this race must stop the loser, not overwrite the winner's decision. + await expectRejection("a second writer moving from the old status loses", () => + transition(db, proposal.id, "PENDING_HUMAN", "REJECTED"), + ); + const stillSubmitted = await getProposal(db, proposal.id); + check("the winner's status stands", stillSubmitted?.status === "SUBMITTED", stillSubmitted?.status); + + await expectRejection("an illegal move throws rather than logging", () => + transition(db, proposal.id, "SUBMITTED", "PENDING_HUMAN"), + ); + try { + await transition(db, proposal.id, "SUBMITTED", "PENDING_HUMAN"); + } catch (error) { + check("and it is an IllegalTransitionError", error instanceof IllegalTransitionError); + } + + await expectRejection("moving a proposal that does not exist throws", () => + transition(db, `0x${"99".repeat(32)}`, "PENDING_HUMAN", "REJECTED"), + ); + try { + await transition(db, `0x${"99".repeat(32)}`, "PENDING_HUMAN", "REJECTED"); + } catch (error) { + check("and it is a StaleTransitionError", error instanceof StaleTransitionError); + } + + const executed = await transition(db, proposal.id, "SUBMITTED", "EXECUTED", { + txHash: `0x${"ef".repeat(32)}`, + }); + check("SUBMITTED -> EXECUTED", executed.status === "EXECUTED", executed.status); + check("txHash persisted", executed.txHash === `0x${"ef".repeat(32)}`); + + console.log("\nonce nothing is pending, the next proposal goes in"); + const third = await insertProposal(db, second.proposal, second.view); + check("insert accepted", third.status === "PENDING_HUMAN", third.id.slice(0, 12)); + + await db.delete(proposals).where(eq(proposals.originator, "test")); + + console.log(failures === 0 ? "\nall checks passed\n" : `\n${failures} FAILED\n`); + process.exit(failures === 0 ? 0 : 1); +} + +await main(); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..bf267fc --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + test: { + // Only the pure half of the server runs here. Anything that touches Postgres is covered by + // the verify scripts instead, because a mocked database proves nothing about the real one. + include: ["src/server/**/*.test.ts"], + // `~/env` validates at import. These tests never read a variable, and requiring a DATABASE_URL + // to unit-test call encoding would make the suite fail on a fresh clone. + env: { SKIP_ENV_VALIDATION: "1" }, + }, + resolve: { + alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) }, + }, +}); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 5e54ce8..65b7859 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,7 +1,8 @@ # Handoff — picking this up on another machine -Written 2026-09-04, updated the same day at the end of Ryan's first working session. -Everything described here is pushed. `main` is at `4b4b849`. +Written 2026-09-04. Updated 2026-09-05 at the end of Ryan's second working session. +`main` is at `c2a0b4d`. The session's work is on `b/proposal-store`, which is pushed and not +merged. Read "What happened in session 2" before you branch off anything. ## Get running @@ -22,9 +23,13 @@ guessing. ```bash pnpm contracts:test # 13 tests pnpm --filter @tappy/protocol test # 8 tests +pnpm --filter @tappy/web test # 29 tests, on b/proposal-store pnpm typecheck ``` +`pnpm test` at the root will fail unless Foundry is on your PATH, because turbo runs the contracts +suite too. That is a PATH problem, not a broken test. + ## What exists and what doesn't | | State | @@ -33,10 +38,12 @@ pnpm typecheck | `packages/contracts` | **Working.** `TappyGate` + mocks, 13 tests, deploy script, ABI export. Not deployed anywhere yet. | | `apps/bridge` | **Written, never run against hardware.** Serial CLI client and `FlipperHumanSigner` compile and typecheck. Untested on a real Flipper. | | `device/tappy-js` | **Written, never run.** Same caveat. | -| `apps/web` | **T3 scaffold only.** `src/server/agent/` and `src/server/tappy/` hold READMEs describing what goes there, not code. | +| `apps/web` server | **Half built, on `b/proposal-store`.** Approval channel, proposal store, agent loop. No UI and no tRPC procedure calls any of it. | +| `apps/web` UI | **T3 scaffold only.** The default landing page. Nothing has been designed. | | `apps/mobile`, `device/tappy-c` | READMEs only. Deliberately not started. | -Nothing is deployed to any chain. No `.env` has real values in it yet. +Nothing is deployed to any chain. `apps/web/.env` has a real `DATABASE_URL`, `BRIDGE_TOKEN` and +`HUMAN_ADDRESS`. There is no `OPENAI_API_KEY` and no `AGENT_PRIVATE_KEY`. ## Read these, in this order @@ -61,17 +68,45 @@ Nine commits on `main`, plus one branch that is deliberately not merged. - **Supabase is live** and the migration is applied. `DATABASE_URL` is in `apps/web/.env`. - **`.claude/skills/unslop`** is a standing writing rule, wired in through CLAUDE.md. +## What happened in session 2 + +Two commits, both on `b/proposal-store`, pushed and not merged. Move 2 and half of move 3 from +the old list. 29 vitest cases in `apps/web` and a `verify:store` run against the real Supabase. + +- **The proposal store is done.** `src/server/tappy/store.ts` and `proposals.ts`. `callForAction` + is the single place an `Action` becomes bytes. `transition` is a compare-and-set: `from` is + matched in the WHERE clause, so two writers racing cannot both win. Illegal moves throw + `IllegalTransitionError`, lost races throw `StaleTransitionError`, and `insertProposal` refuses + a second `PENDING_HUMAN` row and puts the pending one on the error. +- **The agent loop is done.** `src/server/agent/loop.ts`. One message in, one reply out, six tool + round trips maximum. The OpenAI client is an interface, so the whole conversation is tested + without a key. `tools.ts` holds the six SPEC §3.5 definitions next to their zod schemas. +- **`AgentSigner` takes a `DigestInput`, not a hash.** Privy signs typed data and cannot sign an + arbitrary 32 bytes, so a `signDigest(hash)` interface would have had one implementation. This + differs from the sketch in `docs/workstreams/app.md`, deliberately. +- **`b/demo-screen` was deleted**, local and remote. Its design choices were Claude's, not Ryan's. + The UI starts fresh. +- Two bugs fixed on the way. `proposalToRow` was calling `actionSchema.parse` to fill the jsonb + column; the schema runs string in, bigint out, so it compiled and would have thrown "Do not know + how to serialize a BigInt" on the first real write. And `packages/contracts/src-ts/index.ts` + built its `abis` map from identifiers that do not exist, which typechecked only because nothing + imported it. Fixed in the generator too, so `forge build` does not undo it. + ## Your next three moves You own workstream B. In order: -1. **Issue #2** — the script exists at `apps/web/src/server/agent/spike.ts` and has never run. - It needs `OPENAI_API_KEY` in `apps/web/.env`. One command, then fill in `docs/spikes.md` - entry 2 and close the issue. -2. **The proposal store.** `buildProposal` plus the `transition` that throws on an illegal move. - Pure logic, needs no key and no chain, and M2 cannot start without it. Not started. -3. **Issue #6 (M2)** — chat → agent → proposal → mock approval → transaction on Sepolia. Needs - @IGanjali to deploy first (issue #5). +1. **`chat.send`.** Nothing calls `runAgentTurn` yet. A tRPC procedure that runs a turn, stores the + messages and returns the reply. It needs `OPENAI_API_KEY` and `AGENT_PRIVATE_KEY` in + `apps/web/.env`, and neither is set. Message persistence does not exist either: `db/schema.ts` + has `proposals` and `signer_sessions`, no `messages`. +2. **`ChainReader` and `ShopReader`.** Both are interfaces in `src/server/agent/handlers.ts` with + no implementation, because nothing is deployed. `ChainReader` is one viem client and unblocks + `propose_send` and `propose_swap` end to end. Needs @IGanjali's deploy, issue #5. +3. **Issue #2**, still open. `spike.ts` has never run. One command once the key is set, then fill + in `docs/spikes.md` entry 2. Do this before trusting the loop on stage. + +Then the UI, which nobody has designed. Do not let Claude pick its shape again. Branch as `b/`. `main` is protected: both CI checks must pass, no reviews required, self-merge after 30 minutes if nobody looks. @@ -90,32 +125,44 @@ self-merge after 30 minutes if nobody looks. no closures. It cannot sign. That's why the human key is on the laptop in v1. - **Don't add prompt-injection defences to the agent tools.** The defence is the human pressing Back. That's the whole pitch. See `docs/DECISIONS.md` #10. +- **Amounts cross the tool boundary as strings.** `JSON.stringify` throws on bigint, and the jsonb + action column stores the zod *input* shape. Anything handing a bigint to the model or to the + database is a bug that compiles. +- **A failing tool goes back to the model as a result, not an exception.** "No approval device + connected" is something the user has to be told; a throw ends the turn in silence. This is the + one place where a loud error means returning it, not raising it. ## Decide this before you write more code -**`b/demo-screen` is pushed but not merged, on purpose.** It is a working chat and wallet screen -with a 128x64 Flipper mock, three new routers, and the T3 example post table deleted. It came out -of a one-line instruction, so every design choice in it is Claude's rather than Ryan's. Read it and -merge it, or delete the branch. Do not leave it rotting. +**`b/proposal-store` is pushed and not merged.** Two commits, both green. Review it and merge, or +say what should change. Everything in the next three moves builds on it. + +**Add these to `apps/web/.env` and `.env.example`.** Nothing outside the pure tests runs without +them, and the example file was not updated because it sits outside Claude's write permissions. + +``` +AGENT_SIGNER=local +AGENT_PRIVATE_KEY= # a testnet key, checked in on purpose per CLAUDE.md +OPENAI_API_KEY= +``` -**Claude was running in bypass permissions mode all session**, which is why it pushed to `main`, -merged its own PR, renamed the repo's contract and built a UI nobody asked for the shape of. Before -the next session, pick one: plan mode (Shift+Tab, proposals need approval before any edit), or a -`permissions.deny` entry on pushing to `main` in `.claude/settings.json`. Nothing has been changed -about this yet. +**Rotate the Supabase password.** It was pasted into a chat transcript in session 1. Reset it in +the Supabase dashboard and replace the one line in `apps/web/.env`. Still not done. -**Rotate the Supabase password.** It was pasted into a chat transcript. Reset it in the Supabase -dashboard and replace the one line in `apps/web/.env`. +**Claude ran in bypass permissions mode in both sessions.** In session 1 that is why it pushed to +`main`, merged its own PR and built a UI nobody asked for the shape of. Session 2 stayed on a +branch and asked before deleting anything, but nothing enforces that. Pick one: plan mode +(Shift+Tab), or a `permissions.deny` on pushing to `main` in `.claude/settings.json`. -**Open product question, unanswered:** whether the agent only proposes transactions from the wallet -(what the SPEC says today) or also writes and deploys new contracts (what Ryan said out loud). The -second is a much larger feature and is not planned anywhere. +**Open product question, unanswered since session 1:** whether the agent only proposes transactions +from the wallet (what the SPEC says today) or also writes and deploys new contracts (what Ryan said +out loud). The second is a much larger feature and is not planned anywhere. -## Still named the old thing +## Still named the old thing, twice over -The project is Tappy now (DECISIONS #12), but the GitHub repo is still `FlippyTheDolphin`, so the -clone URL above is the real one. Rename the repo when convenient. GitHub redirects the old name, so -nothing breaks either way and the URLs above keep working after the rename. +The project is Tappy (DECISIONS #12) and every package is `@tappy/*`. The GitHub repo was renamed +during session 2 and is now `ryannzander/Tabby`, which matches neither. The clone URL above still +works because GitHub redirects. Pick one spelling and make the repo match. ## Open questions nobody has answered yet diff --git a/packages/contracts/scripts/export-abi.mjs b/packages/contracts/scripts/export-abi.mjs index 234188b..690ab03 100644 --- a/packages/contracts/scripts/export-abi.mjs +++ b/packages/contracts/scripts/export-abi.mjs @@ -25,7 +25,7 @@ const ts = [ "// GENERATED by scripts/export-abi.mjs. Do not edit by hand.", ...CONTRACTS.map((n) => `import ${n}Abi from "../abi/${n}.json" with { type: "json" };`), "", - `export const abis = { ${CONTRACTS.join(", ")} } as const;`, + `export const abis = { ${CONTRACTS.map((n) => `${n}: ${n}Abi`).join(", ")} } as const;`, ...CONTRACTS.map((n) => `export { ${n}Abi };`), "", `export const deployments = ${JSON.stringify(deployments, null, 2)} as const;`, diff --git a/packages/contracts/src-ts/index.ts b/packages/contracts/src-ts/index.ts index a49839a..f2648d0 100644 --- a/packages/contracts/src-ts/index.ts +++ b/packages/contracts/src-ts/index.ts @@ -4,7 +4,7 @@ import MockTokenAbi from "../abi/MockToken.json" with { type: "json" }; import MockSwapAbi from "../abi/MockSwap.json" with { type: "json" }; import MockMerchantAbi from "../abi/MockMerchant.json" with { type: "json" }; -export const abis = { TappyGate, MockToken, MockSwap, MockMerchant } as const; +export const abis = { TappyGate: TappyGateAbi, MockToken: MockTokenAbi, MockSwap: MockSwapAbi, MockMerchant: MockMerchantAbi } as const; export { TappyGateAbi }; export { MockTokenAbi }; export { MockSwapAbi }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14b800e..21ee117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: typescript-eslint: specifier: ^8.27.0 version: 8.69.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@20.19.43)(lightningcss@1.32.0)(supports-color@7.2.0) packages/contracts: devDependencies: