A non-custodial command copilot for Solana.
Type a command in plain English. Review a preview built from the real transaction. Approve, sign with your own wallet, and get a receipt. The server never holds a key and never signs.
Abstract
OttoPump is a non-custodial Solana command copilot. A user connects a wallet, types a plain-English command, reviews a structured preview built from the actual transaction, approves, signs with their own wallet, and receives a receipt. The server never sees a private key and never signs.
The product is a trust copilot, not an autonomous trading agent and not a token-launch farm. Its defensible surface is the path between a natural-language command and a signed transaction: a deterministic intent compiler, a policy engine, and a preview that is generated from real account and token diffs rather than from model prose. The model proposes; app code decides; the wallet signs.
The problem
Most Solana power tooling forces a bad trade. Custodial bots and “agent wallets” are fast but ask the user to hand over keys or fund a hot wallet the app controls. Raw wallets and explorers are safe but slow and unforgiving. A mistyped address or an unverified mint is irreversible.
The recent wave of LLM “do-anything” agents makes this worse, not better. A thin wrapper sends a prompt to a model, lets the model pick a tool, executes from an agent wallet, and then shows the user whatever the model claims happened. That design is neither inspectable nor non-custodial: the user is trusting model text as proof of an on-chain effect.
Thesis
Do Solana actions safely from one command box. The moat is trust, UX, and distribution; the execution layer is composed from existing Solana tooling. OttoPump wins by making the outcome of an action predictable before signing, not by having exclusive access to liquidity or routes.
OttoPump is good when:
- The user can predict exactly what will happen before they sign.
- The app can explain why an action is blocked, in concrete terms.
- Any provider can be swapped without changing the UX contract.
- The non-custodial guarantee holds even when the LLM is wrong.
- The product refuses profitable-but-untrustworthy flows.
The non-custody rule
This is the core invariant. Every production write must satisfy all of the following, in order. If any step cannot be met, the action is not executable.
- The server never stores or sees user private keys.
- The server may build or request an unsigned transaction.
- The client presents a human-readable preview.
- The connected wallet signs.
- The app sends the signed transaction plus a preview token to
/api/execute. - The server verifies the exact signed message and submits it.
- The app records a receipt.
The trust spine
Every write action follows one path. The preview is the product: it is the contract the user approves, and the same message the server later verifies byte-for-byte.
User command → POST /api/intent natural language → typed intent → POST /api/preview validate · build unsigned tx · simulate · sign token → wallet.signTransaction(...) connected wallet signs → POST /api/execute verify exact message · submit to RPC → GET /api/receipts/status poll confirmation → receipt
The two halves are separated on purpose. Intent parsing can be wrong or ambiguous without risk, because it cannot produce transaction bytes. Only the preview stage builds a transaction, and only after deterministic policy checks, a fresh blockhash, a fee estimate, and a successful simulation.
Architecture
OttoPump is a Next.js App Router application. The route handlers are the API surface; the core product modules are kept provider-independent so any RPC, market, or risk source can be swapped behind an adapter without changing the UX contract.
Stack
- Next.js App Router + TypeScript.
- Tailwind v4 (all resets in @layer base, tokens via @theme).
- Solana Wallet Adapter (Wallet Standard / MWA auto-detection).
- Helius-compatible RPC for reads, simulation, and submission.
- An LLM provider for intent parsing (advisory only).
Provider-independent core
No provider is allowed to define the product contract. Each is wrapped behind an internal adapter that returns typed results: UnsignedTransaction, PreviewDiff, ProviderQuote, ProviderRisk.
- Intent compiler: natural language → typed action.
- Policy engine: deterministic allow/deny before any build.
- Preview/diff engine: structured account & token diffs.
- Transaction adapter registry: app-owned unsigned tx construction.
- Submission adapter registry: standard RPC now; Sender/Jito later.
- Wallet signing client: deserialize, check fee payer, sign, serialize.
- Receipt normalizer: uniform receipts across action types.
Route surface
| Route | Purpose |
|---|---|
POST /api/intent | Command → typed intent. Never returns transaction bytes. Ambiguity returns a clarification, not an action. |
POST /api/preview | Validate the action, enforce policy, build the unsigned tx, simulate, and return a signed preview token. Read-only previews return transaction: null. |
POST /api/execute | Verify the preview token and the exact signed message, then broadcast. Never signs. |
GET /api/receipts/status | Normalize pending / confirmed / finalized / failed signature states. |
GET /api/wallet/summary | SOL balance, token counts, closeable accounts, top holdings, degraded-DAS state. |
GET /api/tokens/safety | Parse the raw SPL mint: mint/freeze authority, supply, decimals, risk warnings. |
GET /api/health | Service status without secrets. |
Intent compiler
Natural language compiles into a typed ActionIntent before any tool is called. The LLM can propose an intent, but app code validates it. Raw model text never reaches execution code.
- Parse a command into a typed action with explicit fields.
- Detect missing fields and ask for them rather than guessing.
- Classify a risk level for the action.
- Return a clarification (not a transaction) for anything ambiguous.
- Refuse blocked behavior at the intent layer, before preview.
/api/intent returns a typed envelope:
{ intentId, status, confidence, intent, action,
riskLevel, warnings, missingFields, clarification }When status is clarification, both intent and action are null, so the path cannot proceed to a build.
Preview engine
The preview is the trust product. It is generated from structured data (source and destination accounts, token mints and amounts, fee estimates, account creations and closures, slippage and price impact, and risk flags), never from LLM prose describing what a transaction “should” do.
SOL transfer preview
For a send, the server builds the exact transaction the user will sign and binds it to a stateless token:
format VersionedTransaction (v0), no address lookup tables
instruction SystemProgram.transfer
fee payer connected wallet public key
signer connected wallet public key (exactly one)
fee getFeeForMessage (not a static constant)
simulate simulateTransaction { sigVerify: false, commitment: "confirmed" }
expiry recentBlockhash + lastValidBlockHeight
token HMAC over previewId · messageHash · feePayer · blockhash · LVBHA successful simulation sets executable: true and approval.canExecute: true. A failed simulation keeps the preview non-executable and surfaces warnings. The user is never offered a signature for a transaction that would fail. Preview-token payloads are schema-validated after HMAC verification.
Policy runs first, and fails closed
- Request bodies must contain no private keys, seed phrases, mnemonics, or keypairs.
SEND.sourcemust matchwalletPublicKeywhen a wallet is provided.- SOL sends must stay under user/default limits.
- Source and destination cannot be the same wallet.
- Close-account previews include only zero-balance accounts owned by the connected wallet.
- Swap previews fail closed when
JUPITER_API_KEYis absent or Jupiter returns no taker-owned transaction.
Execute boundary
/api/execute is the narrowest gate in the system. It broadcasts only when every one of these is true:
- The request carries no private-key material.
- The preview-token signature is valid and its payload passes schema validation.
- The previewId matches the token.
- The signed transaction's message hash matches the preview exactly.
- Fee payer and transfer source match walletPublicKey.
- The transaction is v0 with no address lookup tables.
- There is exactly one required signer and one SystemProgram.transfer instruction.
- The wallet signature is present and nonzero.
- The preview blockhash is still valid.
The server never signs. It verifies the bytes the wallet already signed and submits them through standard RPC sendTransaction:
{ skipPreflight: false,
preflightCommitment: "confirmed",
maxRetries: 0 }Modified, expired, unsigned, or wrong-wallet transactions are rejected even if they are otherwise valid Solana transactions.
Why not client-side sendTransaction?
Letting the client broadcast directly would bypass server-side exact-message verification, the core guarantee that what the user approved is what lands on chain. So client-side sendTransaction is out of scope for OttoPump write paths, even though it is a common shortcut elsewhere. Server-side user signing is likewise out of scope.
Additional write paths
- Close-account: exact preview-token message hash plus SPL Token close-account instructions whose owner and destination are the connected wallet.
- Swap: exact server-built Jupiter message hash plus a connected-wallet fee-payer signature. Arbitrary client-built swap transactions are rejected by preview-token mismatch.
Action model
Execution operates on typed action objects, never on raw model text. Each action type has its own preview builder and its own execute-side verification shape.
| Action | State | Notes |
|---|---|---|
READ_ONLY | live | Balances, holdings, status. Preview returns transaction: null. |
SEND | live | Unsigned v0 SystemProgram.transfer, simulated before signing. |
SPLIT | live | Multiple transfers in one transaction where feasible. |
SWAP | live | Jupiter Ultra unsigned tx; requires a token-safety result or explicit override. |
CLOSE_TOKEN_ACCOUNTS | live | Reclaim rent from zero-balance accounts the wallet owns. |
TOKEN_CHECK | live | Raw mint authority/supply/decimals + risk warnings. |
CLAIM | deferred | Only when an exact unsigned user-signed flow is verified. |
TOKEN_LAUNCH_DRAFT | basic live | Basic Pump.fun SOL create/dev-buy uses unsigned tx build, client mint co-sign, wallet sign, and exact-message execute. |
What OttoPump owns
OttoPump must not become a thin Claude wrapper over MCP tools. The defensible surface is the set of modules that sit between the model and the chain, and that keep the safety properties true regardless of the model.
- Intent compiler. Typed intents, missing-field detection, risk classification, refusal of blocked behavior.
- Policy engine. Deterministic rules: connection, allowed action, spend limit, risk threshold, destination checks, high-risk confirmation.
- Preview engine. Trust generated from structured diffs, not model prose.
- Transaction adapter layer. Providers wrapped behind one internal contract.
- Signing boundary. Connected wallets sign; no server signing, no agent-wallet spend.
- Receipt & audit trail. Normalized receipts give the product memory beyond chat.
- Evaluation harness. Command fixtures (valid, ambiguous, blocked, high-risk, and prompt-injection) so intent compilation is reliable by test, not by hope.
Scope & exclusions
V1 optimizes for safe, everyday Solana actions. The exclusions are not a backlog; several are permanent product boundaries.
- Wallet connect
- Balances & holdings
- Token safety checks
- Send & split funds
- Swap via Jupiter unsigned tx
- Close empty accounts / reclaim rent
- Receipts & action history
- Embedded / hot wallets
- Private-key import
- Keeper automations & DCA
- Take-profit / stop-loss execution
- Snipers, bundlers, wash volume
- Vamping, bypass attribution, uPloys
- Fund-flow obfuscation / privacy claims
Threat model
| Threat | Mitigation |
|---|---|
| Server key compromise drains users | Server holds no user keys and never signs, so there is nothing to compromise into a spend. |
| Model hallucinates an action | LLM output is advisory; typed intents are validated and previews come from structured diffs, not model claims. |
| Prompt injection forces a transfer | Blocked behavior is refused at the intent layer; policy runs before any build; the user still sees and signs the real preview. |
| Preview / execute mismatch | The execute path verifies the exact message hash against the signed preview token; any modification is rejected. |
| Replay or stale broadcast | Previews carry blockhash + lastValidBlockHeight; expired previews fail verification. |
| Key material in a request body | Every route rejects private keys, seed phrases, mnemonics, and keypairs before business logic. |
| Malicious / unverified token in a swap | Token safety check is required before swap preview; swap cannot proceed without a result or explicit override. |
Data handling stays minimal: only preferences, limits, labels, receipts, and optional command history. The product does not claim on-chain activity is private, and does not sell or share wallet data.
Status & roadmap
The trust spine is built: intent, preview, wallet-signed execute, receipt status, portfolio reads, token safety, close-account rent reclaim, and Jupiter swaps all run through the non-custodial path above. Current and upcoming work:
- Now. Production frontend: command workspace, preview / receipt cards, read-only holdings and token-safety surfaces.
- Next. Real LLM behind the intent compiler, backed by the evaluation harness; enhanced receipt enrichment.
- Later, behind adapters. Helius Sender and Jito landing for reliability only, never to introduce sniper/bundler behavior.
- Gated. Fee claiming once an exact unsigned flow is verified; token launch only when the launch path returns an unsigned transaction for the connected wallet to sign.
Feedback
This document is a draft shared for technical review. The questions most worth a second pair of eyes:
- Is the execute-side verification list complete enough to reject every transaction that doesn't match its preview?
- Does binding the preview token to (previewId · messageHash · feePayer · blockhash · LVBH) close the replay and substitution gaps?
- Are there write paths where server-built unsigned transactions could be coerced into something the preview didn't show?
- Where would you expect the intent compiler to fail closed but doesn't?
Pushback on the scope boundaries, especially the permanently blocked list, is welcome too. If a flow seems profitable but we’ve ruled it out, that’s usually deliberate; tell us where the line looks wrong.