doppel

CLONE

Any wallet’s double. Every trade, twice.

doppel

How it works

Re
Behavioural cloning of a single wallet, end to end.
Object
A policy that acts as the wallet acts, in the slot it would.
State
Closed. Doubles wake at release.
Handling
Read it twice.

§00Abstract

doppel builds the double of one wallet. A copy-trader echoes a fill after it lands; doppel learns why the fill happened. A policy model is trained on the wallet’s entire on-chain history — every entry, every size, every exit, and the silence between them — then run in shadow against live slots, where it writes down what it would do before the original does it.

When its replay matches the original across the market regimes that wallet has lived through, the double wakes, and trades as that wallet, from yours.

pipeline doppel(target) {
  history  = backfill(target, from = first_signature)
  actor    = siblings(target)            // one trader, many keys
  episodes = segment(history ∪ actor)    // entry → adds → trims → flat
  states   = as_of(episodes)             // the world at each slot, nothing later
  π        = clone(states → actions)     // behavioural cloning + reward prior
  shadow(π, live_slots) until replay(π) ≡ target
  trade(π, signer = you, caps = yours)  soon
}

§01The original

Any wallet can be shadowed; not every wallet should be woken. doppel ranks originals by long, dense, self-consistent histories — styles that held through quiet tapes and hot ones — and refuses everything that only looks like a trader: program-owned accounts, routers, market makers, MEV searchers, exchange hot wallets, airdrop farms.

It also refuses crowded wallets. If a wallet’s fills are already shadowed by copiers inside the same slot, cloning it clones the exit liquidity it sells into.

select target from wallets
where  span(history)            >= W
  and  count(episodes)          >= N
  and  not program_owned(target)
  and  not cex_hot(target)
  and  not searcher(target)      -- bundles against others
  and  copiers_same_slot(target) < C
                                   -- crowded = exit liquidity
order by consistency(target, regimes) desc

§02Backfill, all of it

A wallet’s history is not an API response. Signatures come back newest-first and paged, and the long tail sits in cold archive. doppel walks the address back to its first signature and rebuilds every transaction from the ledger archive itself — epoch CAR files, blocks, entries — because indexer summaries drop exactly what a clone needs: failed attempts, inner instructions, the order of transactions inside a slot, and the compute budget and tip that rode with each one.

cursor = None
loop:
  page = getSignaturesForAddress(target, before=cursor, limit=L)
  for sig in page:
    blk = archive.block(sig.slot)      # epoch → CAR → block
    tx  = blk.find(sig)                # v0, lookup tables resolved
    for ix in walk(tx.message, tx.meta.innerInstructions):
      if ix.program in VENUES ∪ {system, spl_token, token_2022,
                                 ata, compute_budget}:
        emit(decode(ix), key=(sig.slot, tx.index, ix.path))
  cursor = page.last
  until page.empty

VENUES = pump.fun · PumpSwap · Raydium AMM v4 / CPMM / CLMM
       · Meteora DLMM / DAMM v2 · Orca Whirlpools
       · Jupiter v6 routes, resolved to their legs

§03One trader, many keys

Good wallets rarely trade from one key. They fund burners, sweep profit into cold addresses, sell from a sibling while the main stays quiet. doppel walks the funding graph in both directions — SOL and token transfers, shared fee payers, shared rent payers, identical account-creation layouts, timing too regular to be two people — and folds the cluster into one actor.

A sell from a sibling is an exit. A deposit to an exchange is an exit with a delay.

actor(target):
  E = transfers(target, depth <= d, both directions)
    ∪ shared_fee_payer(target) ∪ shared_rent_payer(target)
    ∪ same_ata_layout(target)  ∪ co_timed(target, window=w)
  K = community(E).containing(target)
  K = { k ∈ K : co_trades(k, target)
                ∧ ¬cex_hot(k) ∧ ¬program_owned(k) }
  return Actor { keys: K, root: target }

§04Episodes, and the coins it skipped

The trade log is cut into episodes: first touch of a mint, adds, trims, flat. Each step is labelled — entry, add, trim, exit — with size kept as a fraction of the actor’s bankroll at that moment, never as a raw amount.

The hardest label leaves no transaction at all: the skip. A skip is a coin that crossed the wallet’s field of view and was not bought. Field of view is rebuilt from what the wallet provably watched — devs it bought before, launch windows it trades in, mints it touched later.

struct Episode {
  mint     Pubkey
  actor    ActorId
  open     Slot
  close    Slot | open
  steps    Vec<Step>
}
enum Step {
  Entry { frac_bankroll, route: Venue, cu_limit, tip }
  Add   { frac_bankroll }
  Trim  { frac_position }
  Exit  { frac_position, to: Venue | Sibling | Cex }
  Skip  { seen_at: Slot }   // the negatives carry half the signal
}

§05The world, as of that slot

Every step is paired with the state of the world at its slot and nothing after it: curve reserves, pool depth on both sides, holder graph, dev funding, the actor’s own inventory and cost basis, free SOL, time since its last action, the tape of recent launches, the priority-fee climate. All of it is rebuilt point-in-time from account history, never read from today’s RPC.

One leak from the future teaches the model to be psychic in training and blind live, so the feature store drops any row whose source is newer than its decision slot.

struct State @ s {
  curve    { v_sol, v_tok, r_sol, r_tok, complete }   as_of s
  pool     { depth_bid, depth_ask, lp_burned }        as_of s
  holders  { top_k, concentration, fresh_share }      as_of s
  dev      { funding_hops, prior_launches, sold }     as_of s
  self     { inventory, cost_basis, sol_free, since_last }
  tape     { last_fills(mint, n), last_launches(n) }
  fees     { prio_quantiles(s-w .. s), tip_floor }
}
invariant  ∀ f ∈ State :  f.source_slot <= s
           // else the row is dropped, not patched

§06The policy

The policy is a sequence model over the actor’s own life. Tokens are events — launches it could see, fills on mints it holds, its own actions, transfers between its keys — positioned by slot, not by wall clock.

Four heads, factorized. Whether to act and when, as a hazard over the next slots, so timing is learned rather than thresholded. How much, as a fraction of bankroll. How to leave, as a distribution over trims and exits. Which route, with what compute budget and tip.

πθ(at | e≤t) = Pθ(act, k  | e≤t)      # act at slot t+k
             · Pθ(size    | act, e≤t) # fraction of bankroll
             · Pθ(exit    | size, e≤t)# trim schedule
             · Pθ(route, cu, tip | e≤t)

hθ(k | e≤t) = P(act at t+k | no act in t .. t+k-1)

tokens   launch_seen | fill(mint) | own(step) | transfer(k→k')
position slot, not wall clock
heads    act·k  size  exit  route

§07Cloning, then correcting

Training starts as behavioural cloning: maximize the likelihood of what the actor actually did, in the states it actually saw. Cloning alone drifts. One small mistake puts the double in a state the original never visited, where it has no data, and the error compounds.

So the double is corrected on its own trajectories from shadow. States it reached by itself are labelled with what the original did next, and folded back into training.

L(θ) = − Σep Σt  log πθ(a*t | e≤t)       # cloning
       − λ Σsh Σt log πθ(a*t | e′≤t)      # on shadow states
       + μ · KL( πθ ‖ πψ )                # reward prior

for round in 1..R:
  D_shadow += rollout(πθ, live) ⋈ label(original)
  θ         = fit(D_history ∪ D_shadow)

§08The reward under the style

Copying actions memorizes coins. To act on a coin the actor never saw, doppel also recovers the reward the actor appears to optimize — how much drawdown it sits through, how it trades size against slippage, how fast it gives up on a dead curve, what it does when a dev sells — with maximum-entropy inverse reinforcement learning over the same episodes.

The recovered reward becomes the prior wherever history is thin.

rψ(s, a) = ψ · φ(s, a)
  # φ: drawdown, impact, hold, dev_sold, curve_age, …

ψ*  = argmaxψ  Eactor[ rψ ] − log Zψ      # max-ent IRL
πψ  = softmaxa Qrψ(s, a)
     # the policy the reward implies; prior where data is thin

§09Shadow mode

Before it touches a wallet, the double lives in shadow. It subscribes to live slots over Yellowstone gRPC — accounts and transactions for every venue in the set, at processed commitment, merged with shreds for early order — rebuilds the same as-of state that training saw, and commits what it would do, with a slot, before the original acts.

When the original’s transaction lands, the two are compared. Every miss is kept with the full state that produced it, and becomes training data.

shadow  double/9f2  ↔  original/7Hq…
slot   mint       double           original         verdict
s+0    9vK…pump  entry b3   s+0   entry b3   s+0   match
s+3    9vK…pump  trim  b1   s+3   trim  b1   s+4   match (window)
s+7    Ex2…pump  skip             skip             match
s+9    4Lr…pump  entry b2   s+9   —                miss → kept
s+12   9vK…pump  exit  all  s+12  exit  all  s+12  match
s+12   —          tomb s+11/fork — rows retracted in order

§10Replay match

The double wakes when its replay matches the original — on live slots it has never seen, never on the history it was trained on. Match is defined head by head: decisions agree, sizes fall in the same bucket, exits land inside the same window, skips agree with skips.

The agreement has to hold across every regime the actor trades in, and it has to hold now. You set the bar. Until it is crossed the double stays a shadow, and a shadow never signs.

wake(double) :=
     ∀ head ∈ {act, size, exit, route} :
         agree(head, live[-n:]) >= τhead     # τ: yours
  ∧  regimes(live[-n:]) ⊇ regimes(actor)
  ∧  ¬drift(actor, live[-n:])
  ∧  ¬break(actor)

state  shadow → awake → dormant → shadow

§11Drift, breaks, dormancy

People change. A wallet that shifts style, goes quiet, is handed to someone else or gets drained is a different original. doppel watches the divergence between the double’s action distribution and the original’s live actions.

Slow drift triggers a refit on the newest episodes. A break — a sweep to fresh keys, a new sibling set, a jump in size regime, a change in venue habits — sends the double dormant until it has been shadowed again from zero.

Dt = KL( πθ(· | e≤t) ‖ π̂actor(· | e≤t) )   # ewma, live

if Dt > δrefit  : refit(newest episodes); stay awake
if break(actor) : dormant(double); shadow from zero

break := sweep_to_new_keys ∨ siblings_changed
       ∨ size_regime_jump  ∨ venue_mix_shift

§12Acting from your wallet

When the double acts, it acts from your wallet and nowhere else. You grant a scoped session signer — which programs it may call, a cap per trade, a cap per day, an expiry — and revoke it with one signature. doppel never holds your funds or your seed.

Size is rescaled from the original’s fraction of bankroll to yours, then clipped so your fill moves the curve no more than the original’s did. Transactions go out as v0 with lookup tables, a compute limit fitted to the route, a fee priced from the live climate, and through a bundle when the original’s own history says it bundles.

grant session {
  signer     ed25519 session key      # never your seed
  programs   [pump.fun, PumpSwap, Raydium, Meteora, Orca, Jupiter]
  per_trade  you set
  per_day    you set
  expires    you set
  revoke     one signature, any time
}

sizeyou = clip( frac_bankroll(original) · bankroll(you),
                max_impact(curve | pool, as_of s, like=original) )
tx      = v0 { alts, cu_limit=fit(route), cu_price=climate(s),
               tip=habit(original) }

§13Leading

A double that only follows is late by construction. Once awake, it can act at the slot its hazard head crosses your line — which can come before the original’s own transaction lands. Leading is off by default. It is a setting you turn on, and it stays inside your caps.

lead := awake(double) ∧ enabled_by(you)

at slot t:
  if lead ∧ hθ(0 | e≤t) >= τlead :  send(order)
  else on confirmed(original.tx)    :  send(order)

§14Bait

Wallets that know they are copied lay bait: a loud entry into thin liquidity, sold into the copiers a slot later. To the double, bait is one more pattern of the original — entries it exits inside the copier window, sized against depth instead of bankroll. The double learns the pattern and skips it. It copies how the wallet trades, never how it performs for an audience.

bait(e) := e.exit − e.entry <= copier_window
         ∧ e.size / depth(e.mint, as_of e.entry) is high
         ∧ copiers_same_slot(e.mint) > 0

mask Entry if Pθ(bait | e≤t) >= τbait

§15The web layer

The page you are on is the thinnest layer, and it is still a system. Phantom over Wallet Standard. Sign-In With Solana for sessions, checked server-side against ed25519 with a nonce, never a transaction. The shadow streams to the browser over SSE from the same slot-ordered log the double reads, tombstones included, so what you watch is what it runs.

Every decision is kept as a trace you can open: the slot, the state it saw, what each head said, the order it sent, where it landed.

POST /api/siws                  → session          # ed25519, nonce
GET  /api/shadow/:target  (SSE) → ShadowRow*       # forks tombstoned
GET  /api/trace/:double/:slot   → Trace { state, heads,
                                         order, landed }
PUT  /api/grant                 → Grant            # caps, programs
DEL  /api/grant                 → revoked          # also on-chain

§16Non-goals

There is no signals channel, no leaderboard of doubles to stare at, no custody, and no promise about what a double will make.

signals      no
leaderboard  no
custody      no
promises     no

§17Access

Closed for now. Every wallet that connects is mirrored into the release list. Doubles wake at release.

status    closed
list      every connected wallet, mirrored
ticker    $DDLLPP
release   soon

— end of memo —