From cf55d3cde00a89d4c2a419b211a97d3a12ad7879 Mon Sep 17 00:00:00 2001 From: Ryan Zander Date: Sat, 5 Sep 2026 10:19:50 -0400 Subject: [PATCH 1/4] web: the proposal store and the state machine that guards it buildProposal turns an Action into the exact bytes the human approves, agent-signs the digest and hands back the view the Flipper renders. callForAction is the only place a call is built, so there is one function to audit rather than three. transition is a compare-and-set: `from` is matched in the WHERE clause, so two writers racing to move the same proposal cannot both win. An illegal move throws IllegalTransitionError and a lost race throws StaleTransitionError. Neither logs and carries on. insertProposal refuses while another proposal is PENDING_HUMAN and puts the pending one on the error, because the caller's next move is to show the human what they are already being asked to approve. Two things that were wrong and are now not: - proposalToRow used actionSchema.parse to prepare the jsonb column. The schema runs the other way, string in and bigint out, so this compiled and then threw "Do not know how to serialize a BigInt" on the first real write. Amounts are stringified by hand now, and a test holds it. - packages/contracts/src-ts/index.ts built its `abis` map from bare identifiers that do not exist. Nobody had imported it yet. Fixed in the generator as well, so forge build does not undo it. AgentSigner takes a DigestInput rather than a finished hash. Privy signs typed data and cannot sign an arbitrary 32 bytes, so the hash-shaped interface would have had exactly one implementation. Checked by 16 vitest cases on the pure half and verify:store against the real Supabase for the half a mock cannot answer: wei above 2^53 through text columns, the compare-and-set, one pending at a time. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/package.json | 13 +- apps/web/src/env.js | 11 + apps/web/src/server/tappy/README.md | 28 ++- apps/web/src/server/tappy/agentSigner.ts | 63 ++++++ apps/web/src/server/tappy/proposals.test.ts | 145 +++++++++++++ apps/web/src/server/tappy/proposals.ts | 110 ++++++++++ apps/web/src/server/tappy/store.test.ts | 101 +++++++++ apps/web/src/server/tappy/store.ts | 220 ++++++++++++++++++++ apps/web/src/server/tappy/verifyStore.ts | 152 ++++++++++++++ apps/web/vitest.config.ts | 16 ++ packages/contracts/scripts/export-abi.mjs | 2 +- packages/contracts/src-ts/index.ts | 2 +- pnpm-lock.yaml | 3 + 13 files changed, 854 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/server/tappy/agentSigner.ts create mode 100644 apps/web/src/server/tappy/proposals.test.ts create mode 100644 apps/web/src/server/tappy/proposals.ts create mode 100644 apps/web/src/server/tappy/store.test.ts create mode 100644 apps/web/src/server/tappy/store.ts create mode 100644 apps/web/src/server/tappy/verifyStore.ts create mode 100644 apps/web/vitest.config.ts 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/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/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: From dcf47b94b0c24b96dd8f61376e32a437bfffca67 Mon Sep 17 00:00:00 2001 From: Ryan Zander Date: Sat, 5 Sep 2026 10:31:22 -0400 Subject: [PATCH 2/4] web: the agent tool-calling loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runAgentTurn takes one user message and returns one reply, with up to six tool round trips in between. The client is an interface rather than an OpenAI instance, so the whole conversation is testable without a key: 13 cases drive a scripted client through the tool call, the bad argument, the unparseable JSON, the unknown tool and the runaway model. The six tools from SPEC §3.5 are defined next to their zod schemas, because `arguments` arrives as a JSON string and the model will send a shape that does not match `parameters`. Parse, then validate, then dispatch. One place, so nothing skips it. 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, and a throw would end the turn in silence. The error is still recorded on the turn so callers do not have to read prose. The round-trip cap answers every outstanding function_call before it stops. Leaving one unanswered makes the next request malformed, which reads as a confusing 400 rather than as the cap working. handlers.ts wires the tools to the proposal store and refuses to propose with no device connected. Its chain and shop reads are interfaces: nothing is deployed yet, and the shop tables do not exist. Whoever lands the deploy writes one implementation and no other file changes. Deliberately no injection defence in the tools or the prompt. The defence is the human pressing Back. DECISIONS #10. One known gap is pinned by a test rather than papered over: a wei integer is a valid decimal string, so the schema cannot tell it from a very large amount. Only the tool description stands against it. If spike 2 shows the model doing it, the fix is a bound checked against the balance. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/server/agent/README.md | 31 ++- apps/web/src/server/agent/handlers.ts | 211 +++++++++++++++++++++ apps/web/src/server/agent/loop.test.ts | 251 +++++++++++++++++++++++++ apps/web/src/server/agent/loop.ts | 198 +++++++++++++++++++ apps/web/src/server/agent/prompt.ts | 28 +++ apps/web/src/server/agent/tools.ts | 212 +++++++++++++++++++++ 6 files changed, 923 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/server/agent/handlers.ts create mode 100644 apps/web/src/server/agent/loop.test.ts create mode 100644 apps/web/src/server/agent/loop.ts create mode 100644 apps/web/src/server/agent/prompt.ts create mode 100644 apps/web/src/server/agent/tools.ts 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..ff78d03 --- /dev/null +++ b/apps/web/src/server/agent/loop.test.ts @@ -0,0 +1,251 @@ +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); + }); + + 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..9d28534 --- /dev/null +++ b/apps/web/src/server/agent/loop.ts @@ -0,0 +1,198 @@ +/** + * 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; + + for (;;) { + const response = await client.responses.create({ + model, + instructions: SYSTEM_PROMPT, + input: [...history, ...newItems], + tools: TOOL_DEFINITIONS, + 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. + newItems.push(...(response.output as unknown as OpenAI.Responses.ResponseInputItem[])); + + 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; + // 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, + }, +]; From 88c1880e4a238a92299ea2fc543486523fece1fa Mon Sep 17 00:00:00 2001 From: Ryan Zander Date: Sat, 5 Sep 2026 10:34:14 -0400 Subject: [PATCH 3/4] docs: bring the handoff up to date for session 3 The proposal store and the agent loop exist now, both on this branch. The next three moves are chat.send, the two reader implementations that unblock on the deploy, and spike 2, which is still unrun. Records the AgentSigner shape change, because it differs from the sketch in the workstream brief on purpose. Adds the env lines the next session needs, since .env.example sits outside Claude's write permissions and did not get them. Co-Authored-By: Claude Opus 5 (1M context) --- docs/HANDOFF.md | 105 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 29 deletions(-) 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 From 042dc68e651508b5cadf572fe1af05169f9e707b Mon Sep 17 00:00:00 2001 From: Ryan Zander Date: Sat, 5 Sep 2026 14:57:14 -0400 Subject: [PATCH 4/4] web: the round-trip cap has to bound the requests too MAX_TOOL_ROUND_TRIPS capped the tools that ran, not the calls to the API. Once the budget was spent the loop answered every pending call with "stop calling tools" and went round again, so a model that kept asking kept billing and the turn never returned. The cap now sets forceAnswer. The next request forbids tools and is the last one whatever comes back, and any call in that final response is dropped rather than carried, because nothing will answer it and an unanswered function_call in the history breaks every later turn. The old cap test hid this: its scripted model gave up on its own after twenty responses. The new one never stops asking, and hangs without the fix. --- apps/web/src/server/agent/loop.test.ts | 35 ++++++++++++++++++++++++++ apps/web/src/server/agent/loop.ts | 17 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/src/server/agent/loop.test.ts b/apps/web/src/server/agent/loop.test.ts index ff78d03..a3d7c17 100644 --- a/apps/web/src/server/agent/loop.test.ts +++ b/apps/web/src/server/agent/loop.test.ts @@ -229,6 +229,41 @@ describe("runAgentTurn", () => { 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" }]; diff --git a/apps/web/src/server/agent/loop.ts b/apps/web/src/server/agent/loop.ts index 9d28534..2de1cb4 100644 --- a/apps/web/src/server/agent/loop.ts +++ b/apps/web/src/server/agent/loop.ts @@ -137,6 +137,10 @@ export async function runAgentTurn( 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({ @@ -144,6 +148,7 @@ export async function runAgentTurn( instructions: SYSTEM_PROMPT, input: [...history, ...newItems], tools: TOOL_DEFINITIONS, + ...(forceAnswer ? { tool_choice: "none" as const } : {}), reasoning: { effort }, max_output_tokens: 16000, }); @@ -153,8 +158,15 @@ export async function runAgentTurn( 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. - newItems.push(...(response.output as unknown as OpenAI.Responses.ResponseInputItem[])); + // 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", @@ -163,6 +175,7 @@ export async function runAgentTurn( 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) {