Skip to content

Tappy: the iPhone approval wallet, end to end - #15

Merged
ryannzander merged 29 commits into
mainfrom
b/ios-approval-device
Sep 9, 2026
Merged

ryannzander merged 29 commits into
mainfrom
b/ios-approval-device

Conversation

@ryannzander

@ryannzander ryannzander commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

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.md was written; EIP-7951 (P256VERIFY at 0x100) shipped in Fusaka and made it cost 6,900 gas.

FlippyGate dispatches on signature length: 65 bytes recovers to the Flipper's secp256k1 address, 64 bytes verifies as a Secure Enclave P-256 signature over sha256(digest). One gate, two devices.

Proven on-chain

A P-256 signature moved real ETH through the gate: 0xc7d9932…f15e231. The live gate at 0x35523da59aF7fA1ac12b6ca8686f33EcAf70D634 is configured with a real iPhone's Enclave key.

What's here

  • ios/ — Tappy. Home, Chat, Recipients, Approval, plus a Dynamic Island Live Activity. TappyKit carries 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 — FlippyGate accepting 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_info returns 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

  • The Enclave signs sha256(digest), never the digest — CryptoKit hashes its input with no way to opt out.
  • @noble/curves v2 defaults to prehash: true; leaving it on yields a signature that verifies locally and is rejected on-chain.
  • Next's dev server gives each route its own module instance, so an in-memory store silently loses state between routes.

🤖 Generated with Claude Code

ryannzander and others added 25 commits September 8, 2026 20:39
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>
@ryannzander ryannzander changed the title P-256 gate, GPT agent loop, and two approval devices Tappy: the iPhone approval wallet, end to end Sep 9, 2026
ryannzander and others added 4 commits September 9, 2026 19:45
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
ryannzander merged commit b92bb2b into main Sep 9, 2026
2 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant