---
title: Trading Bot Fork — Specs (draft)
description: Draft specs for a Rukh fork where a context becomes a trading strategy that places bounded Uniswap trades via ERC-7702 delegation, without custody of user funds.
date: 2026-09-13
lang: en-US
author: Julien Béranger
model: Claude Sonnet 5
source: https://julienberanger.com/rukh-trading-bot
---

# Trading Bot Fork — Specs (draft)

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 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):

| 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 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:

```jsonc
// 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):

```ts
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:

```jsonc
{
  "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):

```jsonc
{
  "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](https://docs.uniswap.org/contracts/v4/guides/swap-routing)
</content>
