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

ActorHoldsCan do
User EOA (their existing wallet)The actual fundsEverything — 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 keyThe only address the delegate contract accepts calls from, and only within its bounds
Rukh serverStrategy 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 trade

Steps 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.ts

4.1 provider.service.ts — RPC selection

  • Uses w3pk's exported getEndpoints(chainId) (from w3pk/chainlist, or w3pk.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:

  1. Market data: pool price/liquidity for the pairs a strategy cares about (via Uniswap v4's StateView/quoting helpers — see §7).
  2. User state: token balances of the user's delegated EOA, and possibly DelegateContract events (e.g. PolicyUpdated, AllowanceSpent) so Rukh knows its remaining budget without re-deriving it from tx history. In simulation mode there's no real EOA balance to read — the "balance" is the virtual one tracked by ledger.service.ts instead.

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:

  1. LLM (via existing anthropic/openai/mistral services) reads market data + strategy.json + context markdown, emits a structured proposal (schema below) — never raw calldata.
  2. executor.service.ts validates the proposal against strategy.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 in simulation mode — see §5).
  3. Depending on strategy.json's executionMode (§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).
  4. 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.recordQuery still captures the query-level history as it does today; the ledger is the trade-level record.
  5. 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):

ModeWallet / delegate contract involved?What happens on a valid proposalFunds at risk
simulationNo — neither exists for this contextFetch a real quote (Uniswap V4Quoter, read-only), record it as a simulated fill against a virtual balance sheetNone. No wallet is even configured.
dryRunYes, both must be configuredBuild the real swap calldata and the DelegateContract.execute(...) call, log the fully-formed txNone. Nothing is signed or sent.
liveYesSame as dryRun, then sign with the hot wallet and broadcastYes, 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 the zeroize() pattern already used in mlkem.ts).
  • Not created at all for a context still in simulation mode (§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 V4Quoter contract via eth_call simulation (off-chain, gas-free) to get expected output before committing to a trade size — don't try to read PoolManager storage directly, its state layout is optimized for gas, not readability (needs the StateView helper contract or extsload). This is the only Uniswap interaction simulation mode needs.
  • Execution: go through UniversalRouter, not PoolManager directly — the docs explicitly discourage direct PoolManager calls for swaps due to complexity. Flow: V4_SWAP command → SWAP_EXACT_IN_SINGLE / SWAP_EXACT_OUT_SINGLE action → SETTLE_ALL / TAKE_ALL to close out the flash-accounting deltas → router.execute(commands, inputs, deadline).
  • Approvals: Permit2, not raw ERC-20 approve to the router. The delegated EOA (now running DelegateContract code) approves Permit2 once per token, then Permit2 grants UniversalRouter a scoped, time-bounded allowance — this composes naturally with the "bounded delegation" theme: even the token approval layer is capped and expiring.
  • uniswap.service.ts owns: address book per chain (PoolManager, UniversalRouter, Permit2, V4Quoter, StateView), quote fetching, and command/action encoding. Keep Uniswap-specific ABI/encoding fully inside this service so executor.service.ts only 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.json

9.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 by operatorAddress, only if target/token is on the allow-list, only within remaining daily budget, only before delegationExpiresAt.
  • 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) that watcher.service.ts consumes 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 simulation mode (§5) — only becomes a dependency once a context is promoted to dryRun/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. Removes operatorAddress from 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. Does revoke() 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 the emergencyExitToUSDC() 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 single curl to eth_sendRawTransaction) — no Rukh, no custom interface, no wallet prompt needed at that moment.
  • watcher.service.ts should treat Revoked (§10) as a hard stop signal the moment it's observed — even mid-cycle — rather than waiting for the next poll to notice operatorAddress no longer matches.

11. Phasing

  1. simulation mode end-to-end: provider.service.ts + watcher.service.ts read-only against real mainnet data, executor.service.ts producing simulated fills, ledger.service.ts writing trades.*/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).
  2. strategy.json gains real delegatedAddress/delegateContract/ operatorAddress values and executionMode: "dryRun"executor.service.ts now builds the real swap calldata and the DelegateContract.execute(...) call, logs the fully-formed tx, but never signs or sends it. Ledger/portfolio keep working unchanged.
  3. Delegate contract deployed to a testnet, hot wallet wired up via wallet.service.ts, first real (testnet) end-to-end trade with executionMode: "live".
  4. 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's requestExternalWalletDelegation, 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 simulation mode has to run, and against what criteria, before a context is allowed to request promotion to dryRun/live — this is a product decision, not just a technical one.

[1] Swap Routing on Uniswap v4 | Uniswap Developers </content>