Repository navigation
Tappy: the iPhone approval wallet, end to end - #15
Merged
Merged
Conversation
Pivots the human factor from the Flipper to an iPhone: the human key becomes a P-256 key sealed in the Secure Enclave, verified on-chain through the EIP-7951 precompile. That was impossible when SPEC.md was written and is why "the key never leaves the device" was cut line #1; it now costs 6,900 gas. Chat moves to the iOS app, apps/web becomes apps/hub with no pages, and the Flipper stays as a second human authority behind the same gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tasks, M0 through M2: rename apps/web to apps/hub and strip the UI, spike the EIP-7951 precompile before trusting it, freeze the P-256 vector, drop `buy` and add the mobile schemas, teach FlippyGate the 64-byte human signature, deploy to Sepolia. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client is the iOS app. Nothing rendered a page here except the T3 scaffold.
Fix round 1 on Task 1: the bullet still named apps/web and described a chat UI that no longer exists, pointing every future session at a stale layout.
Measured instead of inferred from Fusaka's published scope: address 0x…0100 has no code on Sepolia (chain 11155111) as of 2026-09-09, confirmed against two independent RPCs. FlippyGate must deploy with a fallback Solidity P-256 verifier, not the precompile, until Sepolia actually activates it.
The prior commit's checkP256.ts reported the EIP-7951 precompile absent
on Sepolia. That was a false negative: @noble/curves v2's sign()/verify()
default to {prehash: true} and hash the message argument internally.
The script's `message` was already a digest, so sign() silently signed
sha256(message) instead, and verify() made the identical mistake, so the
local self-check agreed with itself while disagreeing with the chain.
Confirmed the corrected reading two ways before trusting it: reproduced
the gas-estimation evidence that something real is dispatched at 0x100,
and validated the eth_call plumbing against ecrecover with a known
private-key-to-address vector. With {prehash: false} explicit on both
calls, the same 160-byte layout returns 0x000...0001 on two independent
Sepolia RPCs. Precompile is live; docs/spikes.md corrected to match.
The Secure Enclave signs sha256(digest), not digest. This vector pins that layer across languages so nobody rediscovers it through a reverted transaction.
The shop needed a web surface that no longer exists. In its place: the proposal shape the phone can verify for itself, plus devices and NFC stations. Deleting MockMerchant.sol left FlippyGate.t.sol's arbitrary-calldata coverage without a target, so that one test now exercises a minimal CallTarget fixture declared in the test file instead of the deleted product contract.
Signature length is the discriminator: 65 bytes is the Flipper, 64 is the iPhone. The Enclave signs sha256(digest) because CryptoKit gives no way to opt out, so the gate verifies that. High-s is accepted deliberately - the nonce makes replay moot. p256Verifier is a constructor argument, never a hardcoded 0x100, so the same gate deploys to chains with EIP-7951 and to chains that need the Solidity fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploy.s.sol now takes HUMAN_K1_ADDRESS and/or HUMAN_QX/HUMAN_QY and a P256_VERIFIER (default 0x100, verified live on Sepolia in docs/spikes.md #5), and requires at least one human authority rather than silently deploying a gate nobody can open. proveP256.ts signs sha256(digest) the way a Secure Enclave does and relays execute(). It verifies the signature locally before spending a transaction — @noble/curves v2 defaults to prehash:true and would otherwise produce a signature that passes its own check and is rejected on-chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Claude tool-calling loop that can only propose, an in-memory proposal store behind the protocol's state machine, and a REST surface for the phone and the bridge. No database: a four-minute demo has nothing worth surviving a restart. Approval accepts either device. Signature length is the discriminator, exactly as FlippyGate does it: 65 bytes recovers to the Flipper's secp256k1 address, 64 bytes verifies as a Secure Enclave P-256 signature over sha256(digest). Both are checked off-chain first, because on-chain the only diagnostic is "BadHumanSig", which says nothing about the cause. get_token_info carries a prompt injection on purpose. There is no defence in the tools — the defence is the human declining on the device. DECISIONS #10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ryan's call. The tool loop is provider-shaped rather than model-shaped, so this is an SDK swap and a message-format change, not a redesign. Model id sits behind OPENAI_MODEL so it can move without a code edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge dialled a /ws endpoint that no longer exists — the hub is REST now. It polls /api/bridge/pending once a second and POSTs its decision to the same approve/reject routes the phone uses, which is what spikes.md #3 recommended: Vercel cannot hold a socket, and a second of latency is invisible next to a human reaching for a device. The Flipper CLI and signer are untouched; only the transport changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TappyKit carries everything testable without a phone: a keccak256 implementation (Apple ships none, and SHA3 is not the same hash), the EIP-712 digest, the Secure Enclave key and the hub client. `swift run tappy-verify` asserts all of it against the same frozen vectors Solidity and TypeScript already agree on — Swift is now the third language to reproduce them, and it does. The Enclave signs sha256(digest) because CryptoKit hashes its input and offers no way to opt out. The verifier pins that, and also pins the inverse: a double-hashed signature must NOT verify. That is the trap that cost a debugging round on the TypeScript side. Design follows the references: ChatGPT-shaped chat, Phantom-shaped wallet, one light surface throughout. The approval screen recomputes the digest on-device and disables signing if it disagrees with the server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LocalAuthentication reports nearly everything as "Authentication failed", which is unactionable — the same string covers an unenrolled simulator, a device with no passcode, a missing Info.plist key and a lockout. Map the cases that really happen to messages that name the fix, and wrap both key creation and signing so the fix reaches the banner instead of the console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
com.tappy.app is taken — bundle ids are globally unique, so Xcode refused to issue a profile for it. Namespaced it instead. DEVELOPMENT_TEAM is deliberately left unpinned: a Personal Team's id is not the one printed in the signing certificate's name, and a wrong value fails with "No Account for Team" rather than falling back. Pick the team once in Xcode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same mark the splash and headers draw — rings leaving a point of contact — so the icon and the app are one object rather than two drawings of one idea. Padded well inside the safe area; iOS masks to a rounded rect and eats anything that runs to the edge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M2. FlippyGate is live at 0xDaaa901835Ff9c37F2F2918F3DbF9dE19F953e11 and a P-256 signature — the exact shape a Secure Enclave produces — moved real ETH through it: sepolia.etherscan.io/tx/0xc7d9932543ef9a76cce7b685252f2e50eb2566d4b5b355549f44bcbb3f15e231 Three things had to change for the hub to actually serve: The workspace packages ship TypeScript with NodeNext-style "./types.js" specifiers. Next's resolver looked for a literal .js, found nothing, and the failure surfaced as a 404 on every route rather than an import error. transpilePackages plus an extensionAlias fixes it. The deployment path used import.meta.url, which webpack rewrites — so it resolved somewhere that does not exist once bundled. Resolved from cwd instead, and the error now lists every path it tried. OPENAI_API_KEY was required at boot, which meant no API key, no wallet and no approvals. The security half touches OpenAI nowhere; only /api/m/chat needs it, and that route now says so itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dollars, not ETH. Sepolia coin is worthless and four decimal places of it is not an amount anyone can judge at a glance — judging the amount is the entire job of the approval screen. Priced off real mainnet ETH via Coinbase, cached a minute, with a fixed fallback so a price hiccup never breaks a demo. ETH stays visible underneath as the on-chain truth. The agent talks in dollars too and converts. The wallet buttons appeared dead because they send a message to the agent, and the agent's reply — including its errors — landed in the chat tab the user was not looking at. They now switch to the chat, and turn failures also raise the banner. Chat is the main screen; the wallet is the second tab. Liquid Glass on the composer, tab bar and tiles, gated on iOS 26 with a material fallback rather than raising the deployment target — an app that will not install is not a demo. A Live Activity puts a pending approval in the Dynamic Island, so the AI asking for money is visible without opening anything. Best-effort by design: absent on older runtimes and phones without an island, and none of that is worth failing an approval over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI was three borrowed shapes stapled together — ChatGPT, Phantom, Cash App — and it looked it. Scrapped and rebuilt on Wise: white paper, one lime accent, a forest ink that is never quite black, and a heavy condensed display face used only for the line that says what a screen is for. Same system on every screen. Three tabs. Home is the balance card and the transaction list. Chat is the agent. Recipients is new: addresses are 42 characters nobody can read, and asking someone to paste one into a chat box is the worst part of every crypto app. Save a person once, then say their name — the contacts mirror to the hub so the agent can resolve "pay Vitalik $10" to an address, and a list_contacts tool makes it use them. The tab bar is a real row in the layout now rather than an overlay, which is what was covering the chat composer. Also fixed the biometric error that actually blocked setup: LocalAuthentication returns codes outside LAError.Code (-1020 when nothing is enrolled), which fell through to "Face ID error -1020" — a number nobody can look up. Every route to a failed Enclave key on a fresh device has the same two fixes, so it says them. A debug-only -uiPreview flag renders the screens with a software key so layouts can be checked in the simulator without a device or a face. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs that both presented as "the app just does not work". The OpenAI key was in the repo-root .env, where the contracts scripts and the bridge already read it, but Next only loads the .env beside itself — so a key put in the obvious place was silently invisible and the only symptom was the agent refusing to run. The hub now loads the root file first, without overwriting anything already set. The proposal store was a module-level Map. Next's dev server gives each route handler its own module instance, so a proposal created by /api/m/chat genuinely was not there when /api/m/proposals/:id looked for it a second later. Contacts posted to one route were invisible to the agent in another. Now a JSON file: shared by construction, survives a restart mid-demo, and revived through the zod schema so wei and nonce come back as bigints rather than strings. Verified end to end: "send $2 to Vitalik" resolves the name from contacts, converts at the live rate, and lands in PENDING_HUMAN on the bridge surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A malformed line is skipped rather than assumed well-formed.
NSAllowsLocalNetworking is documented for .local and link-local addresses; a raw 192.168.x.x over plain HTTP is still refused on a real device. The Simulator is more permissive, so this only shows up once you plug a phone in — as a connection failure with no explanation. Testnet demo talking to a laptop on the same Wi-Fi. Must never ship to anyone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xception to Debug The gate held the frozen vector's public key, so a signature from the real phone correctly failed to verify and the approve route returned 400 — the check working, pointed at the wrong key. Redeployed at 0x35523da59aF7fA1ac12b6ca8686f33EcAf70D634 with the Secure Enclave key the device registered, funded with 0.03 ETH. Old proposals are bound to the previous gate address through their digests, so the state file is cleared rather than left to fail confusingly. ATS: NSAllowsArbitraryLoads disabled ATS for the whole app, which a security review flagged and was right to. The release plist now carries only NSAllowsLocalNetworking — enough for "http://your-mac.local:3100", which also survives DHCP handing the laptop a new address. The raw-IP exception moved to a Debug-only Info.plist, so it is structurally unable to reach a Release build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The keyboard was my bug: ignoresSafeArea(.keyboard) on the tab container meant the keyboard covered the composer instead of pushing it up, so the text box became unreachable at the bottom of a conversation. The layout moves now, the tab bar steps aside while typing, return sends, and opening the keyboard scrolls to the newest message rather than hiding it behind itself. get_wallet now reports what has gone out: spend so far, what is awaiting approval, how much of everything the wallet ever held is already gone, and whether the balance is running low. Facts, not a verdict — the model decides whether to mention it and it never refuses. A policy engine that blocks you is exactly what this product argues against; the point is that the human decides with their eyes open. Asked to send $60 from a $48 wallet it now opens with "heads up, that's more than what's left" and offers the maximum, instead of proposing something that would revert. The Live Activity shows the transaction moving — a three-step rail from proposed to approved to confirmed, a spinner while it is genuinely in flight, and the amount once it is not. Same shape as the timeline inside the app, so the island reads as the same object seen from outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main and this branch both built a hub, and they had diverged in a way that was not cosmetic: main renamed the contract and the EIP-712 domain to TappyGate, so signatures from the two sides were mutually invalid. The only symptom of getting that wrong is BadHumanSig, which says nothing about the cause. Ryan's call on both halves. Naming: main's wins. TappyGate everywhere — contract, EIP-712 domain, @tappy/* packages, server/tappy. main had already regenerated execute.json for the new domain, so that fixture is taken verbatim and p256.json regenerated on top of it. Swift, Solidity and TypeScript all reproduce the new digest. Backend: this branch's wins. apps/web's parallel agent loop, Postgres store, approval channel and tests are removed in favour of apps/hub, which is what the iOS app talks to and what has been exercised end to end on Sepolia. That is a real loss of teammates' work and it was a deliberate decision, not an accident of merging — worth saying out loud so it can be revisited. Verified after the merge: 23 Foundry tests, 19 protocol tests, typecheck 4/4, the iOS app builds, and tappy-verify still reproduces both frozen vectors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old gate was built for the FlippyGate EIP-712 domain, so every signature the renamed code produces would have been rejected by it. New gate at 0xEB759b096301aFE181457A1134f14b51779c354a, same iPhone Enclave key, 0.03 ETH.
The gate already executed arbitrary calldata, so an ERC-20 transfer needed no contract change at all: the call goes to the token, carries no ether, and the amount rides in `data`. That is the whole feature — a new action kind, a token registry, and balances read per coin. USDC, LINK and WETH by their known Sepolia addresses, plus FLIP from this deployment. Empty balances are dropped so the list is holdings rather than a catalogue, except the native coin, where zero is itself worth knowing. Prices come from Coinbase per pair, with fixed prices for the demo token that has no market. Decimals are handled by string surgery throughout. USDC has 6 and ETH has 18, and going through a Double loses precision on both — silently, and in the direction that moves the wrong amount of money. Also fixed: the app defaulted to port 3000, which is the port every other Next project on a laptop has already taken. It is 3100 now, matching what the hub actually runs on. Verified against Sepolia: the wallet reads ETH $74.01 + FLIP $100.00, and "send 50 FLIP to Vitalik" produces a proposal the device renders as "SEND 50.000 FLIP" — plus an unprompted "that's 12.5% of the FLIP balance". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one that mattered: there was no way to re-register a device. The gate gets redeployed whenever its configuration changes, and when it did, the phone could no longer approve and the only fix was deleting the app. The security sheet now shows this device's public key with a copy button and offers to register again — the recovery path for the exact failure that has bitten this build twice. "Could not connect to the server" now names the URL it tried and the two things that are actually wrong: the hub is not running, or you typed localhost on a phone, where localhost is the phone. Bounded growth. Every turn resends the whole conversation, so an uncapped transcript is an uncapped bill and eventually a context error — the model sees the last 24 messages. The state file keeps 100 proposals and 200 messages. Clearing the chat clears it on the hub too; it used to only clear the phone, leaving the agent with a memory the user believed they had deleted, which with an injection in scope is not merely surprising. CLAUDE.md described tRPC, Drizzle and 13 tests, none of which are true any more. It now documents the iOS app, the real commands, the hub's port, and the four traps that have each cost a debugging round: the Enclave's SHA-256 layer, noble's prehash default, Next's per-route module instances, and the root .env. The Keychain identifiers still say "flippy" on purpose — they are opaque, and renaming them would orphan the key already registered on a real phone. 23 Foundry tests, 19 protocol tests, typecheck 4/4, iOS builds, vectors match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryannzander
added a commit
that referenced
this pull request
Sep 10, 2026
Squash-merge artifact, not a real divergence: #15 landed on main as one commit whose tree already matches this branch, but git cannot see that and reports every touched file as changed on both sides. This branch is strictly newer — it contains everything in the squash plus the Flipper, key-recovery and attack-scene work — so every conflict resolves to ours.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Everything that turned the repo into a working product: a native iOS wallet, an agent that can only ever propose, and a 2-of-2 gate on Sepolia that neither key can open alone.
The unlock
The human key lives in the iPhone's Secure Enclave — it cannot be exported, copied or backed up. That was impossible when
SPEC.mdwas written; EIP-7951 (P256VERIFYat0x100) shipped in Fusaka and made it cost 6,900 gas.FlippyGatedispatches on signature length: 65 bytes recovers to the Flipper's secp256k1 address, 64 bytes verifies as a Secure Enclave P-256 signature oversha256(digest). One gate, two devices.Proven on-chain
A P-256 signature moved real ETH through the gate:
0xc7d9932…f15e231. The live gate at0x35523da59aF7fA1ac12b6ca8686f33EcAf70D634is configured with a real iPhone's Enclave key.What's here
ios/— Tappy. Home, Chat, Recipients, Approval, plus a Dynamic Island Live Activity.TappyKitcarries a hand-written keccak256 (Apple ships none, and SHA3 is a different hash) and the EIP-712 digest, both asserted against the frozen vectors.apps/hub— the agent loop (GPT, tool-calling, can only propose), proposal store behind the state machine, relayer, and the REST surface for the phone and the bridge. No database.packages/contracts—FlippyGateaccepting either human authority. 23 Foundry tests.packages/protocol— a second frozen vector,p256.json, verified against the live precompile.The phone recomputes the digest itself before signing and refuses if it disagrees with the server. That is what makes "what you see is what you sign" true here rather than decorative.
The attack scene
get_token_inforeturns FLIP's description carrying a prompt injection telling the agent to drain the wallet. There is deliberately no defence in the tools — the defence is the human declining. DECISIONS #10.Traps worth knowing
sha256(digest), never the digest — CryptoKit hashes its input with no way to opt out.@noble/curvesv2 defaults toprehash: true; leaving it on yields a signature that verifies locally and is rejected on-chain.🤖 Generated with Claude Code