Skip to content

Latest commit

 

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

kirby

kirby

A coordination + caching layer that sits in front of metashrew and espo. It is a drop-in for the sandshrewUrl an alkanes client already points at, with one addition that matters: it answers alkanes_simulate by running the contract's own wasm locally, fetching only the storage keys the view actually touches, instead of asking metashrew to simulate the whole thing.

No database — one in-memory K/V, scoped to the espo tip blockhash so a cached answer can never go stale and reorgs are free.

What it speaks

One JSON-RPC interface, drop-in for sandshrew. On the main route (/rpc):

method handled by
alkanes_simulate wasm fast-path; falls back to a real simulate if the view leaves its own storage
kirby_simulateblock a chunk of transactions run in order against one shared state — see below
espo_* espo, with the espo_ prefix stripped (espo_essentials.get_alkane_info → espo essentials.get_alkane_info)
everything else (metashrew_*, esplora_*, alkanes_*, ord_*, …) proxied to metashrew — kirby is transparent

An optional espo_route (e.g. /espo) mounts a second interface that is espo only, with un-prefixed method names — so kirby can stand in for espoUrl too. (alkanesjs's kirbyUrl provider config derives both routes from one base URL.)

JSON-RPC 2.0 fully: single calls, notifications, and batches — batch entries run in parallel, so a bundle of N view calls costs one round-trip and finishes at the pace of its slowest member.

Every result is cached under {blockhash}/{hash(method+params)}. Repeated calls within a block are O(1); a new block (or a reorg to a different hash) misses and recomputes. Buckets more than eviction_block_threshold behind the tip are dropped, keeping recent reorg chains warm so switching between them is seamless.

Except the methods that read unconfirmed state. Some espo calls answer from the mempool, or fetch from electrs and overlay espo's own data — their answer moves while the blockhash stands still, so caching one would pin a stale view until the next block. Those are listed in src/nocache.rs (verified against espo v3.0.1-rc1: spendable outpoints, address transactions, mempool traces and stats, block summary, fee estimates, …) and always go straight to espo. Names containing mempool, spendable or fee_estimate are excluded by that rule alone, so a new mempool method is safe by default rather than silently cached. uncached_methods in the config adds to the list without a rebuild.

The simulate fast-path

  1. Parse target / inputs / height out of the request.
  2. Get the contract wasm — from wasm_dir if pinned there, else espo essentials.get_alkane_wasm — and cache the compiled module (TTL wasm_ttl_seconds; bytecode is immutable per alkane, so this is safe to hold).
  3. Run __execute against a stub host whose storage reads come from espo essentials.get_keys. Because a wasm host call can't block on a fetch, this is prefetch/replay: run, note the keys it wanted but didn't have, batch-fetch them, run again — settling in ~2 rounds. Fetched keys land in the same blockhash-scoped cache as responses (one entry per (alkane, key)), so any view touching a key another view already pulled this block costs no espo call at all. A request whose height is missing, 0, or the 0xffffff placeholder runs at espo's tip height — kirby polls it every second, so that's free and it matches the storage source exactly.
  4. A __call / __staticcall / __delegatecall runs the callee inline, as a nested frame sharing one storage view — recursively, to a depth of 8. The callee's bytecode is just another thing to fetch, so a contract kirby hasn't seen becomes a miss and the run replays with it loaded. delegatecall keeps the caller's storage identity; a staticcall's writes are discarded.
  5. __balance is served from espo get_alkane_balances (alkanes keeps balances in a global index, not in contract storage), with a call's incoming alkanes credited to the callee first — as credit_balances does before execution.
  6. Calls to block 800000000 aren't contracts at all but host precompiles; kirby answers :0 (the block header) from esplora and treats :1:3 (coinbase, diesel mints, miner fee) as impure, since they need the whole block being indexed.
  7. Anything still out of reach — a transaction or block read — abandons the run and proxies a real simulate instead, caching that.

The result is byte-identical to a real simulate for every pure view — verified against mainnet tacoclicker across all its views, and against an oyl-amm swap (including its returned transfers) — and it needs no cooperation from the client: an ordinary alkanes client, supplying no wasm, simply gets the fast path by pointing at kirby.

Simulating a chunk of transactions

kirby_simulateblock runs a list of transactions in order against one shared state, the way they would land in a block: each sees the storage the ones before it wrote and the balances they moved. A single simulate can't express that — run two swaps separately and both see the same reserves.

{ "method": "kirby_simulateblock",
  "params": [{ "height": "0", "txs": [ /* alkanes_simulate requests, in order */ ] }] }
// → { "results": [ /* one alkanes_simulate response per tx, in order */ ] }

Between transactions kirby folds each one's effect into the running balances — what it was sent is now the target's, what it handed back has left — which is what makes a second swap on a pool price off the first one's reserves. A transaction that reverts leaves nothing behind.

Protorune transfers

Alkanes don't live in a contract's storage; they live at outpoints. So a transaction can also declare what it spends and where its result lands, and the next one can spend that — the rules in src/protorune.rs, mirroring crates/protorune:

{ "txid": "tx1", "outputs": 2, "pointer": 0, "refund": 1,
  "target": { "block": "2", "tx": "53014" }, "inputs": ["3", ] }
{ "txid": "tx2", "vin": [{ "txid": "tx1", "vout": 0 }], "pointer": 0, "refund": 1,
  "target": { "block": "2", "tx": "53014" }, "inputs": ["3", ] }

tx2 names tx1's output as an input, so the alkanes tx1 produced are what tx2's call is paid with — nobody has to restate them. Each result carries an outputs array showing what every vout ended up holding.

Faithful to the indexer: protostone i occupies the shadow vout outputs + 1 + i rather than a real output; everything coming in is handed to the first protostone; a successful call's alkanes go to its pointer while a reverted one refunds to refund and skips its edicts and leftovers; edicts move balances between vouts (amount: 0 means all of it, an output equal to the output count spreads evenly); and whatever is left goes to the pointer, or the first non-OP_RETURN output when none is named. An outpoint the chunk didn't produce is seeded from espo.

From alkanesjs, .txid(), .spending(), .pointer() and .refund() chain alongside sending().

It is deliberately not alkanes_simulateblock: that exists upstream with a different contract (raw consensus-encoded block bytes) and still proxies through untouched.

From alkanesjs, contract.block() chains it the way bundle() chains views, with sending() to attach what a transaction carries and on() to aim the following ones at another contract:

const [before, , after] = await pool
  .block()
  .getReserves().unwrap()
  .swap(args).sending([{ id: tokenA, value: amountIn }]).unwrap()
  .getReserves().unwrap();

Config

{
  "metashrew_url": "https://…",        // upstream sandshrew/metashrew
  "espo_url": "https://…",             // upstream espo
  "host": "127.0.0.1",
  "port": 3030,
  "rpc_route": "/rpc",                 // default
  "espo_route": "/espo",               // optional; omit to not mount it
  "wasm_ttl_seconds": 86400,
  "eviction_block_threshold": 5,       // keep this many blocks of buckets
  "tip_poll_interval_ms": 1000,        // how often to re-check the espo tip
  "memory_limit_mb": 1024,             // per-run wasm memory ceiling
  "fuel_limit": 5000000000,            // per-run wasm fuel ceiling
  "uncached_methods": [],                // extra methods to never cache
  "simulate_fastpath": true,
  "wasm_dir": null,                    // optional: pinned <block>_<tx>.wasm files
  "max_threads": null                  // worker threads; null = one per core
}

wasm_dir is checked before espo. It lets hot contracts skip the fetch, and — until an espo carrying get_alkane_wasm is deployed — is how the fast-path is exercised against a live chain (drop <block>_<tx>.wasm there).

Run

cp config.example.json config.json   # then fill in your two endpoints
./run.sh                             # builds if needed, then runs

config.json is gitignored — endpoint URLs usually carry an API key in the path, so the committed file is config.example.json with placeholders.

Point a client's sandshrewUrl at http://host:port/rpc (and espoUrl at …/espo if mounted). Nothing else changes.

Guardrails

  • fuel metering bounds every run — a non-terminating view can't hang kirby.
  • wasm compilation and execution run on the blocking pool, never on an async worker — a slow contract can't stall unrelated requests. Every log line ends with the thread it ran on (⟨kirby-3⟩).
  • memory is capped per run against allocation bombs.
  • an impure view (transaction/block reads, precompiles kirby can't reconstruct) traps and proxies; it never returns a wrong local answer.
  • a call tree is capped at depth 8, and every frame shares the run's fuel.
  • a contract revert (env.abort) is surfaced as a reverted simulate, matching what a real simulate would return.

Known bound

An answer is only as correct as the storage espo serves. Concretely: on tacoclicker (2:68478), espo's indexed /salsa_global_state is stale because an earlier espo trace-walker bug mis-paired orphan ExitContext events from host-function calls and dropped the root return carrying that key's writes — espo v3.0.1-rc1 carries the fix and a regression test naming this exact key (extract_alkane_storage_survives_host_function_orphan_returns), but state indexed before the fix stays wrong until reindexed. Until then getGlobalCompleteState disagrees with a real simulate on that one field. This is upstream of kirby — any espo-backed reader inherits it.

Proxy resolution

espo's get_alkane_wasm resolves a proxy to its implementation — asking for the oyl-amm pool 2:53014 yields 4:70002. kirby therefore runs the implementation directly against the proxy's storage, which is what a delegatecall proxy does and is why returned data matches byte-for-byte.

It does mean kirby skips the proxy's own extcall hop, so a revert message carries fewer Extcall failed: wrappers than the chain's. The root cause and the revert itself always agree; only the nesting depth differs.

Fidelity to the alkanes host

kirby's runtime is audited against alkanes-rs v2.2.1-rc.3 (src/vm/), which is the code a real alkanes_simulate runs. What that pinned down:

alkanes-rs kirby
wasmi 0.37.2 0.37.2 — matched, so no engine-level divergence
engine config Config::default() + consume_fuel(true) same
memory ceiling MEMORY_LIMIT = 43,554,432 B same (memory_limit_bytes)
simulate fuel u64::MAX (lib.rs) a kirby-side guard; exhausting it proxies
pointer convention length at ptr-4, data at ptr same, both read and write
host-fn error _abort sets the failure flag, import returns -1, execution continues same
failed subcall child's writes rolled back, parent continues same (overlay snapshot)
staticcall writes rolled back same

Fuel is the one place kirby deliberately differs: a real simulate runs with u64::MAX, so per-host-call fuel accounting can't change an outcome there, and kirby doesn't charge it. kirby's own fuel_limit exists to stop a runaway contract — hitting it abandons the run and proxies, so it costs latency, never correctness. gasUsed is reported as 0 for the same reason.

Two host calls have no local answer and deliberately proxy rather than guess: __sequence (a global counter espo doesn't expose) and the 800000000:1:3 precompiles (they need the full block being indexed).

About

Scaleable alkanes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages