Skip to content

Commit 4111dcf

Browse files
authored
feat (wallet): web app handoff for signing (#96)
1 parent a55957e commit 4111dcf

31 files changed

Lines changed: 3009 additions & 79 deletions

CONTEXT.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Zerion CLI
2+
3+
Command-line interface for AI agents to analyze wallets and trade on-chain. Builds transactions locally and (today) signs them with a local keystore. An in-progress experiment moves **transaction signing** out of the CLI and into the Zerion web app.
4+
5+
## Language
6+
7+
**Transaction signing**:
8+
The act of turning a fully-formed unsigned transaction into a signed, broadcast, confirmed on-chain transaction. Covers `send`, `swap`, `bridge`, `consolidate`.
9+
_Avoid_: "signing" unqualified — the CLI also does **message signing**, which is out of scope for the web-app handoff.
10+
11+
**Message signing**:
12+
Off-chain signing: EIP-191 / EIP-712 (`wallet sign-message`, `wallet sign-typed-data`) on EVM, and raw ed25519 message signing (`wallet sign-message --chain solana`) on Solana. Local by default; routes to the **web-app handoff** via the message link contract when the wallet is read-only or `--review` is passed. EVM hands off an EIP-191/EIP-712 request; Solana hands off a raw-bytes request (`kind: "solanaMessage"`). EIP-712 typed data stays EVM-only (Solana has no typed-data equivalent).
13+
14+
**Web-app handoff**:
15+
The flow where, instead of signing locally, the CLI builds a fully-formed signing request (a transaction bundle or an EIP-191/EIP-712 message), encodes it into a web-app link, opens the browser, and waits for a localhost callback with the result.
16+
17+
**Signing route**:
18+
The per-request decision between **local signing** and the **web-app handoff**. Production model: local signing is the default; the handoff fires when a trigger hits (read-only wallet, USD value above the wallet's threshold, or an explicit force flag). One routing function shared by `send`/`swap`/`bridge` (`decideSigningRoute`); a value-less sibling for messages (`decideMessageSigningRoute` — read-only and `--review` triggers only, since review thresholds are USD amounts and messages have no sell-side value).
19+
20+
**Read-only wallet**:
21+
A first-class saved wallet ("my wallet") that has **no secret material** — just a name + address in a dedicated registry (not the OWS keystore, not the watchlist). Created with `zerion wallet add <address|ens|solana-pubkey> --name <name>` (EVM 0x, ENS resolved once at add time, or base58 Solana pubkey). The stored address's ecosystem fixes which chains it can sign for — an EVM read-only wallet refuses `--chain solana` and vice versa. Appears in `wallet list`; read commands work normally; **transaction signing and message signing always route to the web-app handoff**; key-requiring commands (export-key, backup) error clearly.
22+
_Distinct from_: **watch entry** — a watchlist address used only for tracking/lookups, never a wallet.
23+
24+
**Review threshold**:
25+
A per-wallet USD amount; any transaction whose value exceeds it routes to the **web-app handoff** instead of auto-signing. Set with `zerion wallet set-review-threshold <wallet> <usd|off>`; stored as a `reviewThresholds` map in `~/.zerion/config.json` (same pattern as `walletOrigins`). Unset/`off` = always auto-sign.
26+
Valuation: **sell-side value, per bundle** — send = amount × token price; swap/bridge = the quote's sell-side USD; an approve inherits its bundle's value; gas never counts. Prices come from `market_data.price` via `getFungible`. **No price ⇒ fail closed** (route to review with a stderr note).
27+
28+
**Link contract**:
29+
The URL shapes the web app owns. Transactions: `https://<host>/cli/transaction?address=<signer>#tx=<base64url(deflateRaw(JSON.stringify(payload)))>`; each `transactions[]` entry carries either `{ evm: TransactionEVM, label? }` (EVM) or `{ solana: { raw: <base64 unsigned tx> }, label? }` (Solana). Messages: `https://<host>/cli/message?address=<signer>#msg=<same codec>`, whose payload carries exactly one **message request**`{ kind: "personal", raw: <0x-hex bytes>, display?: <utf8 text>, chainId }` for EIP-191, `{ kind: "typedData", typedData: {domain,types,primaryType,message}, chainId }` for EIP-712, or `{ kind: "solanaMessage", raw: <0x-hex bytes>, display? }` for Solana (raw ed25519, no chainId). The `address` query param is a 0x address for EVM and a base58 pubkey for Solana. The CLI is the producer of these links; the web app is the consumer.
30+
31+
**Callback**:
32+
A fire-and-forget `POST http://127.0.0.1:<port>/` the web app sends back with signing progress/result (`signed` / `completed` / `rejected` / `failed`; the message handoff has no `signed` progress event, and its `completed` carries `signature` instead of `hashes`).
33+
Production hardening (decided 2026-07-03): the payload carries a **one-time nonce** the web app must echo in every callback POST — events without it are ignored; and on `completed` the CLI **verifies each reported hash on-chain** before reporting success (trust-but-verify).
34+
35+
## Relationships
36+
37+
- A **web-app handoff** replaces **transaction signing**'s local sign+broadcast+wait step; the CLI still builds the fully-formed transaction bundle.
38+
- A **link contract** carries 1–N transactions (all same chain) or exactly one message request, plus an optional **callback** port.
39+
- **Message signing** uses the same handoff machinery (shared callback listener, one-time token, browser open) via the `/cli/message` link; its trust-but-verify step validates the returned **signature** against the signer address instead of checking hashes on-chain — viem `verifyMessage`/`verifyTypedData` (EOA + ERC-1271) for EVM, ed25519 verify for Solana.
40+
- The handoff's **callback path is ecosystem-agnostic**: on-chain hash checks and signature verification are supplied by the caller as an injected `client` (viem for EVM; a Solana adapter from `chain/solana-handoff.js``solanaReceiptAdapter` maps a returned signature to success/reverted via `getSignatureStatuses`, `solanaMessageVerifier` checks the ed25519 signature). `handoff.js` itself imports no chain SDK.
41+
42+
## Notes
43+
44+
- The **link contract**'s EVM transaction is the Zerion API's hex-string `TransactionEVM` shape (not viem's BigInt shape). The CLI fills `nonce` + `gas` but leaves `maxFee`/`maxPriorityFee`/`gasPrice` **null** — the connected wallet in the web app estimates fees at sign time.
45+
- The signer named in the link is the CLI's own resolved wallet address; the human connects a wallet controlling that address in the browser.
46+
- **Callback port** is ephemeral (`listen(0)`), read back, and baked into `payload.port` before the browser opens. Server binds `127.0.0.1` only, no auth token (localhost dev experiment). Terminal events `completed`/`rejected`/`failed` resolve & exit (0 / non-zero); `signed` is stderr progress. Default wait timeout 5 min (`--timeout`).
47+
- **Browser open** is best-effort per-platform `child_process` (no new dep); the URL is always printed to stderr so headless/agent environments can open it manually.
48+
- **Web-app base URL**: `ZERION_WEB_APP_BASE` env → config `webAppBase` → default `https://app.zerion.io`; path `/cli/transaction` is constant.
49+
- On the handoff path the CLI **does not** require the agent token / passphrase (no keystore unlock), but **still** runs `enforceExecutablePolicies` as a pre-filter.
50+
- **Production gating** (decided 2026-07-03): all pre-sign checks — balance gates (native + ERC-20), `quote.blocking`, `enforceExecutablePolicies` — run on **both** signing routes before a link is formed or a tx is signed. Only `requireAgentToken` is local-route-only.
51+
- **Policy-script path check escape hatch**: `ZERION_UNSAFE_POLICY_PATHS=1` (env var only, never persisted) skips the `POLICIES_DIR` containment check in `enforceExecutablePolicies`, printing a loud stderr warning on every run where it's active. Debug-only.
52+
- The experiment's **hard-replace** at the sign call sites (local code commented with `RESTORE:` markers) is temporary; production restores local signing behind the **signing route** decision.
53+
- **Bypass paths refuse**: commands that cannot hand off (`consolidate`) exit with a clear error when the wallet is read-only or the value exceeds its **review threshold** — the guardrail never downgrades to a warning. (Solana `swap`/`bridge`/`send` and message signing now hand off — see below.)
54+
- **Message signing via web app** (WLT-1687, implemented 2026-07-08): `sign-message`/`sign-typed-data` route through `decideMessageSigningRoute` — read-only wallet or `--review` hands off via the `/cli/message` link, everything else signs locally. On the handoff path no agent token is required; the returned signature is verified against the signer address when a verifier is available (fails on definite mismatch, warns-and-accepts when verification can't run). `export-key`/`backup` on a **read-only wallet** still error clearly (no key material).
55+
- **Solana handoff** (implemented 2026-07-08): `swap`/`bridge`/`send` on Solana and `sign-message --chain solana` now hand off to the web app on the same triggers as EVM (read-only wallet, `--review`, value over threshold). Transactions carry the API's unsigned base64 tx (`transaction_swap.solana.raw`) or, for native `send`, a locally-built unsigned transfer (`buildUnsignedSolanaTransfer`, balance-gated); the connected Solana wallet signs + broadcasts and returns the signature as a `hashes` entry, verified via `getSignatureStatuses`. Solana messages sign the raw bytes with ed25519, verified by pubkey. **Read-only Solana wallets** are now first-class (base58 address). EIP-712 typed data and `consolidate` remain EVM-only.

cli/commands/trading/bridge.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { getSwapOffers, pickOffer, isQuoteExecutable, executeSwap } from "../../utils/trading/swap.js";
1+
import { getSwapOffers, pickOffer, isQuoteExecutable, executeSwap, executeViaWebApp } from "../../utils/trading/swap.js";
22
import { requireAgentToken, parseTimeout, parseSlippage, handleTradingError } from "../../utils/trading/guards.js";
33
import { resolveWallet, resolveDestination } from "../../utils/wallet/resolve.js";
4+
import { reportHandoff } from "../../utils/web-app/handoff.js";
5+
import { decideSigningRoute } from "../../utils/trading/signing-route.js";
6+
import { bundleSellUsd } from "../../utils/trading/valuation.js";
47
import { print, printError } from "../../utils/common/output.js";
58
import { formatBridgeOffers } from "../../utils/common/format.js";
69
import { validateTradingChainAsync } from "../../utils/common/validate.js";
@@ -195,6 +198,7 @@ export default async function bridge(args, flags) {
195198
}
196199

197200
try {
201+
// Balance precondition gate — runs regardless of signing route.
198202
if (quote.preconditions.enough_balance === false) {
199203
printError("insufficient_funds", `Insufficient ${quote.from.symbol} balance`, {
200204
suggestion: `Fund your wallet: zerion wallet fund --wallet ${walletName}`,
@@ -220,18 +224,26 @@ export default async function bridge(args, flags) {
220224
},
221225
};
222226

223-
const passphrase = await requireAgentToken("for trading", walletName);
224227
const timeout = parseTimeout(flags.timeout);
225-
const result = await executeSwap(quote, walletName, passphrase, { timeout });
226228

229+
// Sell-side USD value drives routing (bridge = inputAmount × price(from)).
230+
const usdValue = await bundleSellUsd({ fungibleId: quote.from.fungibleId, amount });
231+
const { route, reason } = decideSigningRoute({ walletName, force: flags.review, usdValue });
232+
process.stderr.write(`Signing route: ${route}${reason}.\n`);
233+
234+
if (route === "web-app") {
235+
const result = await executeViaWebApp(quote, { address, timeout, isBridge: true });
236+
reportHandoff(quoteSummary, result);
237+
return;
238+
}
239+
240+
// Local route — unlock the keystore, sign/broadcast, poll bridge delivery.
241+
const passphrase = await requireAgentToken("for trading", walletName);
242+
const result = await executeSwap(quote, walletName, passphrase, { timeout });
227243
print({
228244
...quoteSummary,
229-
tx: {
230-
hash: result.hash,
231-
status: result.status,
232-
blockNumber: result.blockNumber,
233-
gasUsed: result.gasUsed,
234-
},
245+
signedVia: "local",
246+
tx: { hash: result.hash, status: result.status, blockNumber: result.blockNumber, gasUsed: result.gasUsed },
235247
bridgeDelivery: result.bridgeDelivery,
236248
executed: true,
237249
});

cli/commands/trading/send.js

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import { requireAgentToken, parseTimeout, handleTradingError, enforceExecutableP
44
import * as api from "../../utils/api/client.js";
55
import { getPublicClient, broadcastAndWait, signAndSerialize } from "../../utils/trading/transaction.js";
66
import { resolveWallet } from "../../utils/wallet/resolve.js";
7+
import { toTransactionEVM, toSolanaTransaction, signViaWebApp, reportHandoff } from "../../utils/web-app/handoff.js";
8+
import { decideSigningRoute } from "../../utils/trading/signing-route.js";
9+
import { bundleSellUsd } from "../../utils/trading/valuation.js";
710
import { print, printError } from "../../utils/common/output.js";
811
import { getConfigValue } from "../../utils/config.js";
9-
import { getEvmAddress } from "../../utils/wallet/keystore.js";
1012
import { NATIVE_ASSET_ADDRESS } from "../../utils/common/constants.js";
1113
import { formatSwapQuote } from "../../utils/common/format.js";
1214
import { validateTradingChainAsync } from "../../utils/common/validate.js";
1315
import { isSolana } from "../../utils/chain/registry.js";
1416
import { sendSolanaNative } from "../../utils/chain/solana-send.js";
17+
import { buildUnsignedSolanaTransfer, solanaReceiptAdapter } from "../../utils/chain/solana-handoff.js";
1518

1619
const ERC20_TRANSFER_ABI = parseAbi([
1720
"function transfer(address to, uint256 amount) returns (bool)",
@@ -114,18 +117,18 @@ export default async function send(args, flags) {
114117
},
115118
};
116119

117-
// Agent token required — no interactive passphrase for trading
118-
const passphrase = await requireAgentToken("for trading", walletName);
119-
120120
const client = await getPublicClient(chain);
121-
const walletAddress = getEvmAddress(walletName);
121+
// resolveWallet already returned the wallet's EVM address; use it directly
122+
// so read-only wallets (no keystore entry) resolve too.
123+
const walletAddress = address;
122124

123125
const [nonce, feeData] = await Promise.all([
124126
client.getTransactionCount({ address: walletAddress, blockTag: "pending" }),
125127
client.estimateFeesPerGas(),
126128
]);
127129

128-
// Balance check: prevent broadcasting doomed transactions (include gas cost for native)
130+
// Native balance gate — must cover amount + estimated gas. Runs regardless
131+
// of signing route (defense in depth ahead of the human review).
129132
const balance = await client.getBalance({ address: walletAddress });
130133
const estimatedGasCost = 21000n * (feeData.maxFeePerGas || 0n);
131134
if (isNative && balance < amountParsed + estimatedGasCost) {
@@ -139,6 +142,8 @@ export default async function send(args, flags) {
139142
const baseTx = {
140143
type: "eip1559",
141144
chainId: client.chain.id,
145+
// Fees are set for the local sign path; toTransactionEVM drops them for
146+
// the web-app handoff (the connected wallet re-estimates at sign time).
142147
maxFeePerGas: feeData.maxFeePerGas,
143148
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
144149
nonce,
@@ -158,7 +163,7 @@ export default async function send(args, flags) {
158163
process.exit(1);
159164
}
160165

161-
// ERC-20 balance check before broadcast
166+
// ERC-20 balance gate.
162167
try {
163168
const tokenBalance = await client.readContract({
164169
address: tokenAddress,
@@ -187,19 +192,37 @@ export default async function send(args, flags) {
187192
tx = { ...baseTx, to: tokenAddress, value: 0n, data, gas };
188193
}
189194

190-
await enforceExecutablePolicies({ to: tx.to, value: tx.value, data: tx.data });
191-
const signedTxHex = await signAndSerialize(tx, chain, walletName, passphrase);
195+
// Policy pre-filter — runs on BOTH signing routes, before a link is formed
196+
// or a tx is signed.
197+
await enforceExecutablePolicies({ to: tx.to, value: tx.value, data: tx.data, chain });
198+
192199
const timeout = parseTimeout(flags.timeout);
193-
const result = await broadcastAndWait(client, signedTxHex, { timeout });
194200

201+
// Sell-side USD value drives the routing decision (send = amount × price).
202+
const usdValue = await bundleSellUsd({ fungibleId: resolved.fungibleId, amount });
203+
const { route, reason } = decideSigningRoute({ walletName, force: flags.review, usdValue });
204+
process.stderr.write(`Signing route: ${route}${reason}.\n`);
205+
206+
if (route === "web-app") {
207+
const evm = toTransactionEVM(tx, { chainIdNum: client.chain.id, from: walletAddress });
208+
const result = await signViaWebApp({
209+
address: walletAddress,
210+
transactions: [{ evm, label: `Send ${amount} ${resolved.symbol}` }],
211+
timeout,
212+
client,
213+
});
214+
reportHandoff(summary, result);
215+
return;
216+
}
217+
218+
// Local route — unlock the keystore, sign, broadcast, wait for receipt.
219+
const passphrase = await requireAgentToken("for trading", walletName);
220+
const signedTxHex = await signAndSerialize(tx, chain, walletName, passphrase);
221+
const result = await broadcastAndWait(client, signedTxHex, { timeout });
195222
print({
196223
...summary,
197-
tx: {
198-
hash: result.hash,
199-
status: result.status,
200-
blockNumber: result.blockNumber,
201-
gasUsed: result.gasUsed,
202-
},
224+
signedVia: "local",
225+
tx: { hash: result.hash, status: result.status, blockNumber: result.blockNumber, gasUsed: result.gasUsed },
203226
executed: true,
204227
}, formatSwapQuote);
205228
} catch (err) {
@@ -227,7 +250,48 @@ async function sendOnSolana({ token, amount, to, flags }) {
227250
process.exit(1);
228251
}
229252

253+
const summary = {
254+
send: {
255+
token: "SOL",
256+
amount,
257+
from: address,
258+
to,
259+
chain: "solana",
260+
type: "native",
261+
},
262+
};
263+
230264
try {
265+
const timeout = parseTimeout(flags.timeout);
266+
267+
// Sell-side USD value drives routing (send = amount × price(SOL)). Resolve
268+
// SOL's fungible id best-effort; if it's unavailable the router fail-closes
269+
// to review when a threshold is set.
270+
let fungibleId = null;
271+
try {
272+
fungibleId = (await resolveToken("SOL", "solana")).fungibleId;
273+
} catch {
274+
// no id → usdValue null → router fail-closes under a threshold
275+
}
276+
const usdValue = await bundleSellUsd({ fungibleId, amount });
277+
const { route, reason } = decideSigningRoute({ walletName, force: flags.review, usdValue });
278+
process.stderr.write(`Signing route: ${route}${reason}.\n`);
279+
280+
if (route === "web-app") {
281+
// Build the unsigned transfer (balance-gated) and hand it to the web app;
282+
// the connected Solana wallet signs and broadcasts.
283+
const { raw } = await buildUnsignedSolanaTransfer({ from: address, to, amountSol: amount });
284+
const result = await signViaWebApp({
285+
address,
286+
transactions: [{ solana: toSolanaTransaction(raw), label: `Send ${amount} SOL` }],
287+
timeout,
288+
client: solanaReceiptAdapter(),
289+
});
290+
reportHandoff(summary, result);
291+
return;
292+
}
293+
294+
// Local route — unlock the keystore, sign, broadcast, confirm.
231295
const passphrase = await requireAgentToken("for trading", walletName);
232296
const result = await sendSolanaNative({
233297
from: address,
@@ -238,14 +302,8 @@ async function sendOnSolana({ token, amount, to, flags }) {
238302
});
239303

240304
print({
241-
send: {
242-
token: "SOL",
243-
amount,
244-
from: address,
245-
to,
246-
chain: "solana",
247-
type: "native",
248-
},
305+
...summary,
306+
signedVia: "local",
249307
tx: {
250308
hash: result.hash,
251309
status: result.status,

0 commit comments

Comments
 (0)