Plan. apply ships it. Between those two words sits
the execution pipeline: a deterministic gate that builds calldata, runs a
simulation, enforces a policy allowlist, and hands the transaction to
Turnkey for signing. The user never sees a wallet popup because there
isn’t one to show. The CLI never holds a signing key.
This page traces what happens between the moment the user types
apply and the moment the transaction lands on chain.
The user-facing flow
What happens on apply
Concretely, applyPlan (in packages/runtime/src/tools/plan/index.ts)
runs five gates in order. Any gate failing aborts the apply before
Turnkey sees the request.
1. Approval state
approve it. This
is a state flag on the session; no signing happens at approval time.
2. Deterministic transaction build
buildExecutionTransaction (in
packages/execution/src/plans/transaction.ts) builds the calldata from
the plan’s tick math, not from anything the LLM produced:
(widthMultiplier, centerOffsetTicks). The execution
layer turns those into bytes.
3. On-chain simulation
simulatePlan runs an eth_call-style preflight against the live chain
state. Catches reverts before any signing happens. Estimates gas,
slippage, and the post-trade range.
4. Policy gate
checkExecutionPolicy (in packages/execution/src/plans/policy.ts)
enforces a hard allowlist before the transaction is allowed to leave
the runtime:
| Check | Reject reason |
|---|---|
plan.risk.verdict !== "reject" | Risk agent rejected this plan |
| No inventory shortfall | Inventory prep is required (e.g. swap first) |
simulation.onchainStatus !== "failed" | Onchain simulation failed: <revert reason> |
| All token approvals sufficient | Approve <token> for Permit2 before applying |
transaction.to matches the allowlisted Position Manager | Transaction target is not the allowlisted Uniswap position manager |
transaction.value === 0 | Native value transfer is not allowed for LP rebalance execution |
POLICY_REJECTED with the
reasons. Nothing reaches Turnkey.
5. Turnkey sign + submit
Only if every gate passes does the runtime call:signAndSubmit (in packages/chain/src/wallet/turnkey/client.ts)
constructs a user-scoped Turnkey client using the session’s
ephemeral keypair, then calls Turnkey’s ethSendTransaction. Turnkey
verifies the request, signs with the user’s sub-organization wallet,
and submits to the chain. The runtime gets back transactionHash and
turnkeyActivityId (Turnkey’s audit trail id).
Sign-in and session lifecycle
The signing setup happens once per hour, not per transaction.- User types
login <email>. CLI calls TurnkeyinitOtp(directly for self-hosted; viaapps/proxyfor the npm-published binary). - Email arrives. User enters the OTP.
- CLI generates a fresh P256 ephemeral keypair locally
(
generateP256KeyPair), hands the public key to Turnkey viaotpLogin. Turnkey returns scoped session credentials (apiPublicKey,apiPrivateKey) bound to that ephemeral keypair. - First-time sign-in provisions a Turnkey sub-organization with its own wallet address. The wallet’s signing key lives inside Turnkey’s secure enclave; the parent organization has read-only access by Turnkey’s architecture.
- Session is persisted to
~/.zuno/session.jsonwith mode0600and a 1-hour TTL.
Security properties at a glance
| Property | Where enforced |
|---|---|
| User’s signing key never leaves Turnkey | Turnkey sub-org isolates the wallet |
| Even Turnkey’s parent org can’t move funds | Sub-organization architecture |
| Session leak has limited blast radius | 1-hour TTL, mode 0600 on ~/.zuno/session.json |
| LLM hallucinations cannot produce a transaction | Calldata built deterministically by buildRebalanceCalldata |
| No transaction without explicit user approval | approvalState === "approved" is required |
| Plan cannot target an arbitrary contract | Allowlist: transaction.to === POSITION_MANAGER_BY_CHAIN[chainId] |
| Plan cannot transfer native ETH | Policy rejects any transaction.value > 0 |
| Plan cannot run if simulation reverts | simulation.onchainStatus === "failed" blocks apply |
| Plan cannot run if approvals are missing | checkApprovals blocks apply with a concrete remediation |
What ships back to the user
apply returns a structured result the CLI renders as a confirmation
panel: plan id, position id, pair, fee tier, old and new range, gas
estimate, transaction hash, and the Turnkey activity id (so the user
can audit the request in their Turnkey dashboard).
Where the code lives
packages/runtime/src/tools/plan/index.ts-applyPlantoolpackages/execution/src/plans/apply.ts-prepareApplypipelinepackages/execution/src/plans/transaction.ts- calldata buildpackages/execution/src/plans/policy.ts- policy allowlistpackages/execution/src/plans/simulation.ts- on-chain preflightpackages/chain/src/wallet/turnkey/client.ts-signAndSubmitpackages/chain/src/wallet/turnkey/auth.ts- OTP, session keys