docs › Paper trading, live trading and the safety model
Paper trading, live trading and the safety model
The PAPER ticket, the simulated wallet, the confirm gate; the LIVE path behind a separate signer process (devnet first), and the invariants it keeps.
Trading in dexterm is PAPER by default: a simulated wallet priced from live, keyless Jupiter quotes. Nothing is signed, no key is held, no transaction is sent, and the paper code imports no wallet or signing library of any kind. Every ticket and positions frame says so in a banner that is never hidden.
A LIVE path exists on main, devnet first and labelled in progress: your own Phantom signs, a separate signer process holds the caps, and a Jupiter swap (a mainnet transaction) is refused unless you start dexterm with --mainnet. It is described in Live trading (devnet first) below.

The ticket#
B or S on a token opens a buy or sell ticket. It shows the side, the size (SOL or % of balance when buying; % of the position when selling), slippage in bps, and a rolling Jupiter quote refreshed every 5 s while you edit: what you pay, what you receive, price in SOL and USD, price impact (as a percent; Jupiter reports a 0..1 fraction and the app converts it once), the simulated fee, the route and the minimum received. Keys: digits type, tab switches field, ←/→ flips the side, u toggles the unit, ⏎ reviews, esc cancels, L flips PAPER ⇄ LIVE (see below). Token amounts are shown as quantities, so a small buy of an expensive token reads 0.0144 AVGOx, never 0; until the token's decimals are known the ticket says so instead of printing a guess.
The confirm gate is on by default. ⏎ arms the order; y (or ⏎ again) confirms. For a notional above 1 SOL you must type the size back digit for digit before the confirm counts. Any other key during the confirm step cancels the arm: a stray key can never confirm, and q or a screen number cannot navigate out from under an armed order. The fill is computed from a fresh quote at confirm time, never from the quote on screen, and is booked into the paper wallet in one SQLite transaction. If Jupiter rate-limits during the confirm the ticket says so, retries for up to 8 s, then lands in an error state you can retry or cancel; it never hangs.
The wallet#
- starts at 10 SOL (configurable), persisted in the same SQLite file as the cache and watchlist (
--db;:memory:for a throwaway session); - books every fill append-only with the simulated fee (5 000 lamports per fill, labelled as a simulation);
- marks positions from DexScreener's USD price over the live SOL price every 10 s on the positions screen;
- shows a
◈ POSITION · PAPERrow under the chart on any token you hold, and the same row plus entry line and fill markers in the chart popup. A confirmed LIVE fill shows the same way, marked live, read from the signer's own ledger.
The analyst can open a PAPER ticket for you (open_ticket, pre-filled, editing phase) when you ask it to buy or sell. It cannot size it past what you typed, arm it, confirm it, or flip it LIVE; it tells you to confirm yourself.
Live trading (devnet first)#
Status: in progress. The code is on main and verified against fakes, a hand-built attack suite and a real recorded Jupiter transaction; the one thing not yet observed is a transaction signed by the real Phantom extension on devnet, which Joey's probe run will settle. Nothing here has touched mainnet.
Link a wallet#
dexterm wallet link (or /wallet link typed on the screener's / line or in the chat box) starts a one-shot server on 127.0.0.1:41291 (a fixed loopback port since task-1368; a random one only if it is busy, and the page then says so) with a single-use token, and opens your browser to it. The page asks Phantom to connect and sign a message (a dexterm nonce): no transaction, no approval of funds. The port is fixed because Phantom remembers which origins it trusts: link and every later sign page share http://127.0.0.1:41291, so after the first Connect prompt Phantom connects silently. dexterm verifies the Ed25519 proof and stores only your public key in ~/.dexterm/wallet-link.json. dexterm wallet status, unlink, /wallet, /wallet unlink show or remove it. No private key, seed or password ever reaches dexterm; it has no code that could hold one.
The signer process#
The first LIVE action starts a separate signer process with exactly the caps flags you started dexterm with (--max-usd, --daily-usd, --max-sol-out, --max-slippage-bps, --trade-rpc, --mainnet). It talks to the terminal over an authenticated pipe whose only methods are status · plan · probe · precheck · confirm · drop · panic: there is no sign(bytes), no submit, no setCaps, and no way to name a wallet other than the one in the signer's own link file. If the signer dies it is never respawned; LIVE stays off until you restart.
The signer owns: the caps and their hard ceilings; the daily USD ledger (SQLite, shared by every dexterm process on the machine, survives a restart); the kill switch; its own HTTP client for Jupiter and the RPC; its own SOL/USD feed (DexScreener and CoinGecko, timestamped, both required within 3% and under 60 s on mainnet); and the wallet bridge. The network it reports and caps by is classified from the RPC endpoint it actually talks to, by exact hostname, never from a flag or a label.
A LIVE ticket#
L on a ticket flips it LIVE when a wallet is linked and the signer is alive (otherwise the ticket says why and stays PAPER). The header shows ◉ LIVE · DEVNET, or ⚠ LIVE · MAINNET — REAL FUNDS in red with the caps. ⏎ asks the signer for a PLAN:
- Quote from Jupiter, bound to your order: the input and output mints, the mode and the exact input amount must match or the plan is refused (
quote-mismatch). - Build the unsigned transaction; decode it with dexterm's own wire decoder (legacy and v0, lookup tables included).
- Policy: every top-level instruction must fit the shape of a Jupiter swap: exactly one route-family instruction (by Anchor discriminator), token operations limited to SyncNative, InitializeAccount for your wallet and CloseAccount back to your wallet, a System transfer only into your own wrapped-SOL account that is synced and closed in the same transaction, associated-token creation only for your wallet. Transfers, burns, approvals, authority changes, direct DEX calls and unknown programs are refused by name.
- Binding: the route's
in_amount,quoted_out_amountandslippage_bpsmust equal the quote; the platform fee must be zero with the "none" sentinel account (dexterm takes no fee); the transfer authority must be your wallet; the source and destination must be your own associated token accounts for the two mints. - Simulation against the RPC, with your wallet and your token accounts in the requested post-state: the SOL leaving your wallet must not exceed the plan plus a fee allowance nor the SOL cap; a buy may not decrease any of your token balances; a sell may spend only the order mint's account and only up to the order amount (an address a lookup table loads counts as part of the transaction). If the balances cannot be read, the plan is refused rather than guessed.
- Price: the SOL leg times the signer's own SOL/USD, never Jupiter's
swapUsdValue; on mainnet an unknown or $0 price refuses.
The PLAN screen lists what will be signed instruction by instruction, what you pay and receive, the minimum received, the impact as a percent, the fee including any priority fee, the route, the simulation result, the SOL/USD price and its sources, the policy verdict and the caps verdict ("will be refused: …" when it would be).
Y (or ⏎) is the keypress gate. The signer re-checks the caps, reserves the USD against the daily ledger (atomically, so two trades cannot both pass), opens the sign page, and hands the bytes to Phantom. What comes back must be the same message with a valid Ed25519 signature by the linked wallet; the only tolerated difference is a Lighthouse assertion tail appended by Phantom. The signed bytes are decoded, policy-checked and bound again, re-simulated with fresh token balances, and only then submitted. A transport error after the send keeps the charge and shows the signature with "do not resend"; a definite rejection before broadcast refunds. Plans are single-use and expire after 90 s.
Before you press ⏎: size vs caps#
Since 2026-10-12 (task-1368) a LIVE buy ticket prices your size at the signer's own SOL/USD (the price its caps are enforced with) while you type, and shows it against the per-trade cap and what is left of the daily cap: 0.04 SOL ≈ $4.32 at SOL $108 · cap $5/trade · $20 left of $20 today. When the size is over, the line turns red before you build anything: 0.05 SOL = $5.40 > $5 cap: use ≤ 0.043 SOL (f fits it; fees + token-account rent counted). f sets that size. The fit is the cap divided by the price minus 0.0025 SOL, rounded down to three decimals: the signer prices the larger of your planned SOL leg and the SOL the simulation actually moves out of the wallet, and a first buy of a token adds a token-account rent (0.00204 SOL) plus the fees on top of the size, so a size that is exactly the cap in USD would be refused at precheck. The daily remainder binds when it is smaller than the per-trade cap; with no SOL/USD from the signer the line says $? and the signer will refuse an unpriced trade on mainnet. The ticket's verdict is a preview; the signer still decides at precheck and again at confirm.
Jupiter rate limits#
Jupiter's keyless API allows about five requests per ten seconds per IP, answers 429 with no Retry-After but with a reset time, and its alternate host (lite-api.jup.ag) can answer 503 (both recorded on 2026-10-12 and kept as fixtures). A limit used to kill the plan (build-failed: HTTP 429) and park the preview on "fetching…" for half a minute. Now every keyless Jupiter call on the trade path is retried on the other host after the server's own reset (or Retry-After, with a jittered backoff), at most three attempts inside ten seconds; a 5xx or a dropped connection alternates the same way; the LIVE preview, the signer's re-quote and the build go through the signer's one rate limiter and a quote fetched for the preview is reused by the build a moment later (two calls, not three). When the budget is spent, or the data layer's health gate is cooling the source down after two 429s in a minute, the ticket keeps the plan: it shows Jupiter is rate limiting this IP (429 from api.jup.ag), retrying in Ns, retries by itself when the wait ends, r retries now and esc cancels. Nothing is built or signed while it waits.
Devnet signer, mainnet token#
Jupiter has no devnet. A Jupiter route is built on mainnet state (its accounts, address-lookup tables and blockhash), so a devnet signer can never simulate or fill a swap of a real token: the simulation fails with an RPC error such as invalid transaction: Transaction loads an address table account that doesn't exist, and the caps refuse it (simulation-failed). The ticket now says this before you press ⏎ on any token while the signer is on devnet (DEVNET cannot fill mainnet tokens: paper trade (L) or restart with --mainnet (real funds, caps $5/$20)), and when a simulation does fail the PLAN shows the full RPC error over several lines (it used to be cut to invalid transaction: Tr…) with a plain-English hint when the pattern is this mismatch; the signer also writes the full error to the app log. Your two options are exactly those: paper trade the token with L, or run dexterm --mainnet with a funded mainnet wallet inside the caps (next section). The devnet signing path itself is proven by the probe, which is built for devnet on purpose.
On devnet a Jupiter swap is refused at the caps step (mainnet-not-enabled): Jupiter has no devnet, so a real swap is a mainnet action. The signing path is proven on devnet instead by **dexterm wallet probe** (or /wallet probe): a transaction dexterm builds itself that wraps 0.001 SOL into your own wrapped-SOL account and closes it back, fees only, through exactly the same keypress → Phantom → verify → re-simulate → submit → confirm path, with its own policy (no route allowed) and never on mainnet.
Panic#
/wallet panic in the app, or dexterm wallet panic from any shell, any time: it writes a kill-switch file every signer re-reads on every plan and confirm, locks the shared cap ledger so no reservation can be taken, tells the running signer to forget its plans and flips an open LIVE ticket to refused. The only way back is dexterm wallet resume in a real terminal: it needs a physical keypress on a TTY and refuses piped input.
Mainnet#
Only with --mainnet. The banner is red, the caps are $5 per trade and $20 per day by default (raise them only with the flag, never above $500 / $2000), 1 SOL out per transaction, 3% impact, 500 bps slippage. dexterm takes no fee; you pay network fees only. Nobody has run a mainnet trade through this path yet, and nothing on this page authorizes one.
Your first live trade#
A walkthrough for one small, real swap inside the default caps. It is the path the verifier follows with a devnet signer (every step up to the confirm behaves the same, and the devnet wall above is where it stops) and on fixtures; nobody has yet run it to a mainnet fill, and nothing here is advice to.
- Link your wallet.
dexterm wallet linkopens Phantom on a loopback page; connect and sign the nonce.dexterm wallet statusshows the public key. dexterm never sees a key. - Fund a small mainnet wallet. Use a wallet that holds only what you are willing to lose plus fees: for a $5 trade at SOL $108 that is about 0.06 SOL (0.046 for the swap, the rest for the token-account rent of 0.00204 SOL, network and priority fees and a reserve). The per-trade cap is $5 and the daily cap $20 by default; the hard ceilings are $500 and $2000 and only
--mainnetcan raise anything. - Start on mainnet with the caps you mean.
dexterm --mainnet --max-usd 5(add--daily-usd 20,--max-sol-out 1,--max-slippage-bps 500to be explicit). The ticket header turns red:⚠ LIVE · MAINNET — REAL FUNDS · caps $5/trade $20/day. The signer process starts on the first LIVE action with exactly these flags, classifies the network from the RPC it talks to, and needs two independent SOL/USD sources within 3% before it will price anything. - Open a ticket and size it under the cap. B on a token, L to flip LIVE, type a size; watch the cap line and press f if it is red (it sets the largest size that fits, fees and rent included). Slippage stays at 50 bps unless you change it; impact over 3% is refused.
- Build the plan. ⏎. If Jupiter rate-limits, the ticket waits and retries by itself (r now, esc cancels). The PLAN lists what will be signed, what you pay and receive, the minimum received, the fee, the route, the simulation result (how much SOL leaves your wallet), the SOL/USD price and its sources, and the caps verdict;
within capsmeans the signer will not refuse it at this moment. - Confirm. Y. The signer re-checks the caps, reserves the USD on the daily ledger, opens the sign page. The page connects first (a Connect prompt the first time on this origin, silent after that), shows the connected account and refuses one that is not the linked wallet (
Phantom is on account … but dexterm linked …), then asks Phantom to sign; approve in Phantom. If Phantom answers 4100 ("not authorized") the page says so and what to do: press Approve again and accept the Connect prompt, or allow127.0.0.1:41291under Phantom's Trusted apps, then confirm the trade again in dexterm; nothing was signed. The signed bytes must be the same message with your signature (a Lighthouse tail appended by Phantom is the one tolerated change); they are decoded, policy-checked, bound and re-simulated with fresh balances, then submitted and confirmed. - Expected outcome.
✓ LIVE MAINNET BUY confirmed — 0.0144 AVGOx @ 2.778 SOL · sig …with the signature; the position appears under the chart marked live and on the positions screen under LIVE POSITIONS; the daily ledger shows the spend (Caps … spent $4.32). A refusal (✗ REFUSED by the signer — per-trade-cap: …) means nothing was signed or sent.send-unknownwith a signature means the transaction may have been broadcast: check the explorer, do not resend. - Panic key.
/wallet panicin the app ordexterm wallet panicfrom any shell disables live signing everywhere at once (kill-switch file, locked ledger, plans forgotten).dexterm wallet resumeon a real TTY is the only way back. - Read the journal.
dexterm wallet statusshows the linked key, the signer's network and caps, today's spend and the panic state. Confirmed real fills live in~/.dexterm/live-trades.sqlite(the signer's own ledger, read by the positions screen); the daily USD reservations in the shared cap ledger~/.dexterm/caps.sqlite; the signer's log lines (including the full text of any failed simulation) go to the app log. Order plans, if you use them, append to~/.dexterm/plans.journal, whichdexterm plan journal --verifyrecomputes.
Order plans and standing mandates#
Merged 2026-10-11 (task-1355) and labelled in progress: the engine, the typed and spoken proposal paths, the O screen, the popup and the audit journal are on main and every default run is paper. Since 2026-10-12 (task-1363) a live fire path on devnet exists behind a held keypress: fills go through the signer process and a separate capped fast wallet, described in the live fire path below. No plan has moved mainnet funds; there is no mainnet fast wallet.
An order plan is "buy this coin if it dips to 300k market cap, sell half at 600k, keep the rest as a moon bag", written down once: one entry trigger (market cap or price, below or above, or immediate), a ladder of take-profit tranches (each an above-trigger with a fraction; whatever the fractions leave is the moon bag), an optional stop (a below-trigger, below the entry), slippage and impact ceilings and an expiry (4 h by default, 7 days at most). dexterm plan propose --mint M --size 20 --entry mc-below:300k --tp mc-above:600k:50% --stop mc-below:150k writes one; so does saying it to Jarvis or an agent's propose_order_plan tool. All three produce the same thing: an inert proposal with a SHA-256 hash of its material fields and a plain-language read-back. A proposal fires nothing.
A standing mandate is the budget a set of plans may spend: a total, a per-trade cap, a per-coin cap, a daily-loss cap, the highest confirmation tier it accepts and a time-to-live. Every cap is clamped to a hard ceiling ($2000 total, $500 per trade, $1000 per coin, $2000 daily loss, 24 h) and nothing is ever coerced upward: there is no "raise cap" anywhere; a different cap is a new draft that needs a new arming gesture. dexterm mandate draft --total 100 --per-trade 20 --ttl-hours 4 writes one.
Arming a plan under a mandate is the only step that lets anything fire, and it is a physical gesture: a key held at the terminal on the O screen (a) or dexterm plan arm <id> --mandate <id> on a real TTY, which refuses a pipe or a subprocess. The gesture proof is bound to the plan hash, single-use and expiring, so an edited plan voids every earlier confirmation. Once armed, a monitor fed by the prices the terminal already shows (the screener rows, the watchlist, the open token; never fixture data) fires the entry, each take-profit tranche and the stop only inside the armed plan and only through the executor, which re-checks the mandate caps on every fill (and, on the live path, the signer re-checks its own caps, allow-lists and the panic marker on top). dexterm plan amend <id> --size 25 --entry mc-below:280k edits a proposed plan: the edit is a new plan with a new hash, the old proposal is superseded and every earlier confirmation is void, so arming it is a new keypress; an armed plan cannot be edited (cancel it first). Cancel (c), revoke (r) and panic (!, or dexterm wallet panic) stop a plan at once; nothing resumes a paused plan without a new keypress.
Two confirmation tiers beyond the keypress exist in the model and are off by default: ptt-held (push-to-talk held while you speak the confirmation) fires at 0.5× the per-trade cap and hands-free at 0.25×, and neither can arm a mandate or a batch. Turning hands-free on is the only "less safe" switch in dexterm: G on the O screen raises a prompt that says so, it is accepted by a keypress only (a spoken, agent or Phantom "yes" is refused), it is journaled, it expires after 24 h and G again restores the gate.
The journal (~/.dexterm/plans.journal) is append-only and hash-chained; every proposal, arm, fire, fill, cancel, revoke, gate change and refusal is a row, dexterm plan journal --verify recomputes the chain and --export dumps it, and it is the source the service recovers from after a crash. The popup is a read-only HUD fed view models only (never a key, a secret or an arm capability); its single write is "cancel this plan", re-validated at the server allow-list and again at the terminal.
What is proven, on fixtures and a fake clock: the north-star plan fills end to end on the paper executor; a replayed price stream fires each leg once; and a compromised-model suite shows that spoken text, agent text, a forged proof, a replayed proof, a widened mandate and an edited plan with an old confirmation all fail to arm, raise a cap, widen a mandate, resume a paused plan, accept the risk prompt or switch the fire path.
The live fire path (devnet, the fast wallet)#
A standing mandate is meant to fire at machine speed, which a Phantom approval per fill cannot do. So the live path uses a second, capped fast wallet: the one key dexterm ever holds, created only by dexterm wallet fast create after a real keypress on a TTY, stored at ~/.dexterm/fast-wallet.json at mode 600, devnet only (the record says so, the signer process loads it only against a devnet RPC, and there is no mainnet variant, no export and no import). dexterm wallet fast status shows its public key and devnet balance, dexterm wallet fast airdrop asks the devnet faucet, dexterm wallet fast remove deletes it (refused while it still holds SOL unless --force). Your main wallet is never named by a mandate and the fast wallet never signs for it: the signer refuses either crossing before caps, reservation or signing.
L on the O screen raises the fire-path prompt; a held keypress switches plan fills from PAPER to LIVE · DEVNET · fast wallet (and back), journaled as settings.executor. The switch is refused by voice, an agent, a pipe or the popup (it takes nothing but a keypress), while any plan is armed or holding (its legs would strand on the other ledger), when the run has no signer process (replay, offline, --no-live), when the signer is not on devnet, or when no fast wallet is loaded. Every run starts on paper; the journal never recovers a live switch.
When an armed plan fires on the live path, the engine asks the signer to build a devnet fill for its leg. Jupiter has no devnet, so the transaction is the probe's wrap-and-unwrap shape sized to the plan's SOL leg (0.02 SOL for a $3 entry at $150/SOL), with an on-chain memo naming the plan leg; the signer decodes it, runs the probe-shape policy and the program allow-list, simulates it with the fast wallet's token balances, prices the SOL leg from its own SOL/USD feed and enforces its USD per-trade and daily caps on devnet too (a fill with no trustworthy price, or above the per-trade or remaining daily budget, is refused and the reason, per-trade-cap, daily-cap, no-usd-value, is what the plans journal records) plus a hard 0.05 SOL per-fill ceiling. The confirm re-checks and reserves the USD, has the fast wallet sign, verifies the signature and the message, re-simulates the signed bytes, submits and confirms. The engine journals an intent before anything is sent, treats the send as in flight until the signer answers, settles it by lookup and never resends; an outcome the signer could not determine expires in the engine after 90 seconds as unseen (the fast wallet is capped, so exposure is bounded). The plan's token leg is modelled from the fire-time price, because no token changes hands on devnet, and the journal labels the fill devnet-fill with modelledTokens: true, the real signature and the real SOL delta (the fees). A mainnet fill through the fast wallet is refused by construction.
What is proven here, on fakes with the real decoder, the real Ed25519 signer and the real executor pipeline: a fill is built, signed by the fast wallet (Phantom is never asked), confirmed and booked with the USD ledger charged; the ceiling, a missing price, the per-trade cap and the daily cap each refuse before anything signs; a mainnet signer or a missing fast wallet refuses the build; a fast-lane plan confirmed with the main wallet's key, or a main-lane plan built for the fast wallet, is signer-mismatch; the whole path works over the real authenticated child-process channel; and the engine-side adapter turns the signer's answers into the journal rows above. What is not: a fill confirmed on devnet with real faucet SOL (the public faucet answered an internal error on the day), and any mainnet action.
Trade moments#
The cyberspace look reacts to a trade, but only to its result. A confirmed buy fires a green shockwave from the ticket with coins streaming up; a profitable sell a gold burst and gold rain; a loss red static that collapses down the screen (no shake, no mockery); a signer refusal or a failed send a red lockout flicker in the bands; and while a send is in flight a neutral cyan scanning sweep that is never a verdict. Normal and fever tiers differ in length and density; --calm shows none of it and the result card still lands at once; any key skips a running moment.
Two things are mechanical, not conventions. Only a settled outcome can fire a moment: the ticket reports exactly one outcome per booked paper fill, confirmed live fill, signer refusal or send failure, and the effects engine refuses anything without a confirmed-or-failed status; a quote, a PLAN, an armed order or a send-unknown (the chain may still confirm it) fires nothing, and a test checks the one call site. The ticket is shielded: every cell of the ticket and the PAPER / LIVE banner is marked off-limits on the cell buffer, so rain, glitch, datamosh, scanlines and the moments cannot write a glyph, a colour or a background into it in any phase (tested byte-identical against a calm render at 176, 120 and 90 columns). --sound (or DEXTERM_SOUND=1) adds short synthesized cues after the same outcome: a buy chirp, a sell sweep then a chime or a thud, a buzz on a failure. Muted by default; the WAVs are written once to ~/.dexterm/sounds and played detached so the render loop never waits.
What the live path keeps#
These are the invariants from the spec; hold us to them:
- Non-custodial. You hold the keys. dexterm builds, simulates and hands off; your wallet signs. The Phantom bridge stores only your public key.
- Keypress gate on by default. Models and voice create proposals; arming needs a physical keypress or a wallet approval. Opting out, if ever offered, needs an explicit "this is less safe" confirmation, is local, logged and reversible, and the signer caps still apply.
- Caps live in the signer, not the UI. The signer re-simulates and re-checks every transaction (per-trade and daily USD caps, price-impact and slippage ceilings, the SOL-out cap, the token-balance diff, a positive description of what a Jupiter swap looks like: exactly one route instruction bound to your order, no token transfers, burns or approvals to anyone) and refuses out of policy even if the UI, the model or a popup is compromised.
- The AI has no interface to the signer. No build, sign or send tool exists for the model; its only write-shaped tool opens a PAPER ticket, and a test proves that even on a live-capable context it yields a paper ticket and the signer is never called. The scan mode and the MCP server import nothing signer-shaped.