Trading Bot Fork — Specs (draft)
Julien Béranger
+ Claude Sonnet 5
Status: draft for discussion. No code changes yet. This document sketches the
architecture for a fork of Rukh where a user's context becomes a trading
strategy that can read Ethereum mainnet state and place trades on Uniswap,
without Rukh ever custodying user funds.
1. Goals
- A user writes/uploads a strategy into a
context(as today), scoped to their SIWE-authenticated address. - Rukh watches Ethereum mainnet (prices, pools, possibly the user's own positions) and periodically asks the LLM, grounded in that context, whether to act.
- When the strategy says "act", Rukh executes a real Uniswap trade on-chain — but only within hard limits the user set in advance, and only against the user's own funds, never Rukh's.
- Rukh's own signing key (the "hot wallet") is a bounded operator, not an owner. Losing it should be an inconvenience, not a fund-loss event.
- A strategy must be runnable end-to-end with no funds and no wallet at all (simulation mode, §5) before it's ever allowed near real money.
- Every trade — simulated, dry-run, or real — is recorded, and a user can always see their current portfolio state (§9).
Non-goals (v1)
- No custody of user principal by Rukh.
- No multi-chain support at launch — Ethereum mainnet + Uniswap v4 only.
- No MEV protection / private mempool submission in v1 (flag as a fast-follow).
- No UI for building the ERC-7702 delegation — assume a thin frontend or script the user runs once, out of this repo's scope.
2. Actors & trust boundaries
| Actor | Holds | Can do |
|---|---|---|
| User EOA (their existing wallet) | The actual funds | Everything — can revoke delegation any time |
| Delegate contract (ERC-7702, code the user's EOA points its execution to) | Nothing (it's logic, not funds) | Enforces the spending policy: allowed targets, token allow-list, per-tx/per-day caps, expiry |
| Rukh hot wallet (one per Rukh deployment, or one per user — see §6) | Nothing but gas + its own key | The only address the delegate contract accepts calls from, and only within its bounds |
| Rukh server | Strategy context, watcher state, hot wallet key (encrypted at rest) | Decides when to trade and what to propose; cannot move funds outside the delegate contract's rules |
The trust-critical property: even if the Rukh hot wallet key is fully compromised, the attacker is bounded by the delegate contract's policy (max size, allow-listed tokens/targets, expiry) — never full account control. In simulation mode there's no hot wallet or delegate contract in the loop at all, so this property is moot: there's simply nothing to compromise.
3. High-level flow
User (browser, has passkey/wallet)
│ 1. one-time: signs ERC-7702 authorization delegating
│ their EOA to <DelegateContract>, and configures policy
│ (allowed tokens, max per-trade size, daily cap, expiry,
│ Rukh's hot wallet address as the allowed operator)
▼
Ethereum mainnet: user's EOA now runs <DelegateContract> code
│
│ 2. ongoing: Rukh watcher polls/subscribes to relevant
│ on-chain state (pool prices, user balances)
▼
Rukh `ethereum/` module → strategy decision loop (LLM + context)
│
│ 3. if the strategy says "trade": ExecutorService validates
│ the proposal against strategy.json bounds, then — depending
│ on executionMode — either records a simulated fill, logs a
│ built-but-unsent tx, or signs with the hot wallet and sends
▼
tx: hot wallet → user's (now-delegated) EOA → DelegateContract.execute(...)
→ Uniswap v4 UniversalRouter/PoolManager
│
▼
LedgerService appends to trades.json/.md and recomputes
portfolio.json/.md (§9), regardless of which mode produced the tradeSteps 1 and the hot wallet only exist for dryRun/live modes — a context
running in simulation mode never touches steps 1 or the delegate contract
at all (§5).
4. ethereum/ module
New NestJS module alongside context/, rag/, web/:
src/ethereum/
ethereum.module.ts
provider.service.ts # RPC endpoint selection & rotation
watcher.service.ts # on-chain listening
executor.service.ts # tx building, guardrail checks, signing, sending
wallet.service.ts # hot wallet key lifecycle (encrypt/decrypt/sign)
uniswap.service.ts # v4 quoting + swap calldata construction
ledger.service.ts # trade history + portfolio state (§9)
dto/
strategy-policy.dto.ts
trade-proposal.dto.ts4.1 provider.service.ts — RPC selection
- Uses
w3pk's exportedgetEndpoints(chainId)(fromw3pk/chainlist, orw3pk.getEndpoints()on an SDK instance — both just proxy the same chain-id.network list, no auth required) to fetch the current public RPC list for chain 1. - Picks a pseudo-random endpoint per session/request (not always the first) to avoid hammering a single public RPC and to reduce the chance any one provider can profile Rukh's trading pattern.
- On failure/timeout, falls back to the next endpoint in a shuffled order; caches a short-lived "known bad" list so a dead endpoint isn't retried every call.
- Config escape hatch: allow an operator-supplied private RPC (Alchemy/ Infura/QuickNode key) to take priority when set, since public endpoints rate-limit hard under any real polling load.
- Used identically in every execution mode — even pure simulation needs real mainnet reads for quoting.
4.2 watcher.service.ts — listening
Two responsibilities, both configurable per context:
- Market data: pool price/liquidity for the pairs a strategy cares
about (via Uniswap v4's
StateView/quoting helpers — see §7). - User state: token balances of the user's delegated EOA, and possibly
DelegateContractevents (e.g.PolicyUpdated,AllowanceSpent) so Rukh knows its remaining budget without re-deriving it from tx history. Insimulationmode there's no real EOA balance to read — the "balance" is the virtual one tracked byledger.service.tsinstead.
Start with polling on a @nestjs/schedule interval per active context
(simplest, matches Rukh's existing request/response service shape, no
long-lived socket to babysit). Revisit WebSocket subscriptions or a
webhook provider (Alchemy Notify / QuickNode Streams) only if polling
latency becomes the bottleneck for a given strategy.
4.3 executor.service.ts — the guardrail
This is the highest-risk piece and should be small, boring, and heavily tested. Pipeline for every proposed trade:
- LLM (via existing anthropic/openai/mistral services) reads market data +
strategy.json+ context markdown, emits a structured proposal (schema below) — never raw calldata. executor.service.tsvalidates the proposal againststrategy.json: token is on the allow-list, size ≤ per-trade cap, cumulative spend today ≤ daily cap, slippage tolerance within bounds, delegation not expired (the last two checks are skipped insimulationmode — see §5).- Depending on
strategy.json'sexecutionMode(§5): record a simulated fill, or build the real swap calldata (§7) and either log it (dryRun) or sign it with the hot wallet and send it (live). - Every outcome — proposed, rejected, simulated, or executed — is appended
to the trade ledger via
ledger.service.ts(§9), and the portfolio state is recomputed.ContextService.recordQuerystill captures the query-level history as it does today; the ledger is the trade-level record. - Any failed check is logged and surfaced to the user (no partial/best- effort execution) — silence on failure is how strategies quietly stop working and nobody notices.
5. Execution modes: simulation, dry-run, live
strategy.json carries one field, executionMode, with three values. A
context can only be promoted to the next mode manually (never auto-escalates
based on performance):
| Mode | Wallet / delegate contract involved? | What happens on a valid proposal | Funds at risk |
|---|---|---|---|
simulation | No — neither exists for this context | Fetch a real quote (Uniswap V4Quoter, read-only), record it as a simulated fill against a virtual balance sheet | None. No wallet is even configured. |
dryRun | Yes, both must be configured | Build the real swap calldata and the DelegateContract.execute(...) call, log the fully-formed tx | None. Nothing is signed or sent. |
live | Yes | Same as dryRun, then sign with the hot wallet and broadcast | Yes, bounded by the delegate contract's policy |
simulation is the default and the only mode available until a user has
completed the ERC-7702 delegation flow. It requires none of §6/§8/§7's
custody machinery — a strategy can be authored, watched, and back-tested
against live market data indefinitely without ever creating a hot wallet or
deploying a delegate contract. This is deliberately the cheapest possible
way to answer "does this strategy do anything sane?" before wiring up real
money.
ledger.service.ts and watcher.service.ts behave the same way in all
three modes (same trade schema, same portfolio computation) — only the
source of truth for balances changes: a virtual ledger balance in
simulation, the real on-chain balance of the delegated EOA in dryRun/
live. This is what lets §9's history and portfolio views work identically
regardless of mode.
6. Hot wallet & key management
Answering the open question from the kickoff: w3pk's wallet flows
(register/login/deriveWallet/signMessage/sendTransaction/
signAuthorization) are gated behind an authenticated SDK session
(this.currentUser, set by a WebAuthn ceremony) — see
w3pk/src/core/sdk.ts:1143-1145 for signAuthorization specifically. That
requires a browser and a platform authenticator; it does not run headless
on a server with nobody present to approve a biometric/PIN prompt. So the
Rukh hot wallet cannot be "a w3pk wallet" in the sense of using those
session-gated methods unattended.
What does work server-side, because it's pure crypto with no WebAuthn
dependency, is w3pk's ML-KEM module (w3pk/src/crypto/mlkem.ts):
deriveMLKemKeypair, mlkemEncrypt, mlkemDecrypt are plain functions
over mlkem + @noble/hashes + WebCrypto — they import and run fine in
Node.
Recommended design:
- One
ethers.Wallet(or one per user, see below) generated server-side — this is the hot wallet / operator key. - Its mnemonic is encrypted at rest using
mlkemEncrypt, keyed to an ML-KEM keypair derived (deriveMLKemKeypair) from an operator secret held outside the repo (env var / secrets manager) — post-quantum encryption at rest, reusing w3pk's crypto without needing its passkey flows. - Decrypted into memory only inside
wallet.service.ts, only for the duration of building+signing a tx, then zeroized (mirror thezeroize()pattern already used inmlkem.ts). - Not created at all for a context still in
simulationmode (§5) — no reason to generate or encrypt a key before it's needed. - One hot wallet vs. one per user: start with a single shared hot wallet address for the whole Rukh deployment, since the delegate contract's policy is what actually bounds the blast radius, not the operator key's uniqueness. Revisit per-user operator keys only if you want per-user revocation without touching every user's delegate contract (i.e. "kill this one user's bot" vs "kill the whole fleet").
7. Uniswap v4 integration
Uniswap v4 is the current version (singleton PoolManager, hook system,
flash accounting) — v3-style per-pool contracts are legacy. Confirmed via
current Uniswap developer docs.footnote:1
- Quoting: use the
V4Quotercontract viaeth_callsimulation (off-chain, gas-free) to get expected output before committing to a trade size — don't try to readPoolManagerstorage directly, its state layout is optimized for gas, not readability (needs theStateViewhelper contract orextsload). This is the only Uniswap interactionsimulationmode needs. - Execution: go through
UniversalRouter, notPoolManagerdirectly — the docs explicitly discourage directPoolManagercalls for swaps due to complexity. Flow:V4_SWAPcommand →SWAP_EXACT_IN_SINGLE/SWAP_EXACT_OUT_SINGLEaction →SETTLE_ALL/TAKE_ALLto close out the flash-accounting deltas →router.execute(commands, inputs, deadline). - Approvals:
Permit2, not raw ERC-20approveto the router. The delegated EOA (now runningDelegateContractcode) approvesPermit2once per token, thenPermit2grantsUniversalRoutera scoped, time-bounded allowance — this composes naturally with the "bounded delegation" theme: even the token approval layer is capped and expiring. uniswap.service.tsowns: address book per chain (PoolManager,UniversalRouter,Permit2,V4Quoter,StateView), quote fetching, and command/action encoding. Keep Uniswap-specific ABI/encoding fully inside this service soexecutor.service.tsonly ever deals with "proposal in, calldata out."
8. Strategy context extensions
Keep ContextService exactly as-is for the qualitative strategy prose
(what it already does well: SIWE-owned, markdown files, query history). Add
one structured sibling file per context, not parsed by the LLM, enforced in
code:
// data/contexts/<name>/strategy.json
{
"chainId": 1,
"executionMode": "simulation", // "simulation" | "dryRun" | "live"
"delegatedAddress": null, // set once ERC-7702 delegation is done
"delegateContract": null,
"operatorAddress": null, // set once a hot wallet exists for this context
"allowedTokens": ["0xTokenA...", "0xTokenB..."],
"maxTradeSizeUsd": 500,
"maxDailySpendUsd": 2000,
"maxSlippageBps": 100,
"delegationExpiresAt": null,
"simulation": {
"startingBalanceUsd": 10000,
"baseCurrency": "USD"
}
}A structured TradeProposal DTO is what the LLM must emit (validated with
class-validator, same pattern as the rest of Rukh's DTOs):
class TradeProposalDto {
action: 'swap';
tokenIn: string;
tokenOut: string;
amountIn: string; // wei, as string
maxSlippageBps: number;
rationale: string; // logged, not executed on
}9. Trade history & portfolio state
Two pieces of state per context, each kept as a JSON file (source of truth,
machine-readable) and a Markdown file regenerated from it (human-readable —
same "always-legible" spirit as the rest of Rukh's file-backed contexts).
Owned by ledger.service.ts, written with the same queued-write pattern
ContextService already uses for index.json to avoid concurrent-write
corruption.
data/contexts/<name>/
trades.json # append-only ledger, every proposal outcome
trades.md # rendered table view of trades.json
portfolio.json # current holdings, derived from trades.json
portfolio.md # rendered view of portfolio.json9.1 trades.json — append-only ledger
One entry per proposal, regardless of mode or outcome — rejections are kept, not discarded, so a user can see why the bot didn't trade, not just what it did:
{
"id": "uuid",
"timestamp": "2026-09-12T10:15:00Z",
"executionMode": "simulation",
"status": "filled", // "filled" | "rejected" | "sent" | "confirmed" | "failed"
"tokenIn": { "symbol": "USDC", "address": "0x..." },
"tokenOut": { "symbol": "WETH", "address": "0x..." },
"amountIn": "500000000",
"amountOut": "0.14", // quoted (simulation/dryRun) or actual fill (live)
"priceUsd": 3571.43,
"rationale": "RSI < 30 and 4h trend up, per strategy rule #2",
"rejectionReason": null, // populated when status === "rejected"
"txHash": null // populated only once status is "sent"/"confirmed" in live mode
}trades.md renders the same data as a reverse-chronological table (newest
first) plus a short header noting the context name and current
executionMode — regenerated in full on every append rather than diffed,
since the file is meant to be read, not patched by hand.
9.2 portfolio.json — current state
Recomputed from trades.json (plus, in dryRun/live, cross-checked
against the real on-chain balance of the delegated EOA read by
watcher.service.ts — the two should always agree; a divergence is worth
surfacing as a warning, not silently trusting the ledger):
{
"updatedAt": "2026-09-12T10:15:00Z",
"executionMode": "simulation",
"baseCurrency": "USD",
"holdings": [
{
"symbol": "USDC",
"address": "0x...",
"amount": "9500.00",
"valueUsd": 9500.00
},
{
"symbol": "WETH",
"address": "0x...",
"amount": "0.14",
"avgCostUsd": 3571.43,
"currentPriceUsd": 3600.00,
"valueUsd": 504.00,
"unrealizedPnlUsd": 4.00
}
],
"totalValueUsd": 10004.00,
"realizedPnlUsd": 0,
"startingValueUsd": 10000.00
}portfolio.md renders this as a holdings table plus a one-line summary
(total value, all-time P&L, since when) — this is the file a user actually
looks at to answer "how is my bot doing," in either simulation or real
money.
10. Delegate contract (out of this repo, but a hard dependency)
Not something to build casually — this is the actual custody boundary and needs a security review before real funds touch it. Minimum required interface:
execute(target, value, calldata)— callable only byoperatorAddress, only iftarget/token is on the allow-list, only within remaining daily budget, only beforedelegationExpiresAt.updatePolicy(...)— callable only by the delegating EOA itself (i.e. the user, via a normal signed tx once they've reclaimed a signing path, or via the same 7702 delegation before Rukh's operator is authorized) — lets the user tighten/loosen limits or revoke Rukh's operator entirely.- Emits events (
Executed,PolicyUpdated,Revoked) thatwatcher.service.tsconsumes to keep Rukh's view of remaining budget in sync without re-deriving it from raw balances. - Consider reusing an existing audited ERC-7702 session-key pattern instead of writing one from scratch (there's active ecosystem work here in 2026 from several smart-account vendors) rather than rolling a bespoke contract for a bot that will be signing unattended.
- Not required at all for a context running in
simulationmode (§5) — only becomes a dependency once a context is promoted todryRun/live.
10.1 Emergency exit — a panic path that never touches Rukh
Because the user's EOA is the one retaining updatePolicy rights (§2, §10),
a "sell everything to USDC, right now" path doesn't have to be a Rukh
feature at all — it can be a plain on-chain call the user makes directly,
with Rukh's server, API, and UI completely out of the loop. This should be
a first-class, required part of the delegate contract, not an afterthought:
revoke()— callable only by the delegating EOA. RemovesoperatorAddressfrom the allow-list immediately. Stops all further automated trading in one cheap call; funds are untouched but safe from the bot. This is the minimum viable panic button and should be gas-cheap enough to always be affordable.emergencyExitToUSDC()— also callable only by the delegating EOA. Doesrevoke()and, in the same transaction, sweeps every allow-listed token balance to USDC. The swap calldata per allow-listed token is pre-built and pre-approved (Permit2 +UniversalRouter) at policy-setup time, so executing the exit requires no LLM call, no Rukh service, and no live quote — it's a single self-contained contract call the user (or anyone holding their key) can fire at any time.- No interface required, even in the degenerate case: because both
functions are called by the user's own EOA, they're invocable from any
generic tool that can send a transaction — a hardware wallet's own UI,
cast send, or pasting into Etherscan's "Write Contract" tab. For the case where no wallet software is available at the moment of the emergency, the user can pre-sign theemergencyExitToUSDC()transaction once, offline, at setup time, and store the raw signed bytes somewhere durable (password manager, printed QR, USB drive). Triggering it later is then just broadcasting those bytes to any public RPC endpoint (e.g. a singlecurltoeth_sendRawTransaction) — no Rukh, no custom interface, no wallet prompt needed at that moment. watcher.service.tsshould treatRevoked(§10) as a hard stop signal the moment it's observed — even mid-cycle — rather than waiting for the next poll to noticeoperatorAddressno longer matches.
11. Phasing
simulationmode end-to-end:provider.service.ts+watcher.service.tsread-only against real mainnet data,executor.service.tsproducing simulated fills,ledger.service.tswritingtrades.*/portfolio.*. No wallet, no delegate contract, no signing anywhere in this phase — and no promotion out of it until a strategy's simulated track record is good enough to trust (see open questions, §12).strategy.jsongains realdelegatedAddress/delegateContract/operatorAddressvalues andexecutionMode: "dryRun"—executor.service.tsnow builds the real swap calldata and theDelegateContract.execute(...)call, logs the fully-formed tx, but never signs or sends it. Ledger/portfolio keep working unchanged.- Delegate contract deployed to a testnet, hot wallet wired up via
wallet.service.ts, first real (testnet) end-to-end trade withexecutionMode: "live". - Mainnet, starting with one internal context, tiny
maxTradeSizeUsd, before opening it up.
12. Open questions
- Confirm whether the ERC-7702 authorization itself should be produced by
the user's existing wallet (MetaMask/Ledger via
w3pk'srequestExternalWalletDelegation, browser-side, one-time) or by a fresh w3pk passkey wallet created for this purpose — both work, but changes what "connect your strategy" looks like in the frontend. - Decide the fee/gas model: does the hot wallet need its own ETH for gas (simplest), or should this route through a paymaster/sponsored-gas path eventually?
- Decide single shared hot wallet vs. per-user (§6) once you know whether per-user kill-switches matter more than operational simplicity.
- Decide how long
simulationmode has to run, and against what criteria, before a context is allowed to request promotion todryRun/live— this is a product decision, not just a technical one.
[1] Swap Routing on Uniswap v4 | Uniswap Developers </content>