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.
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.
- Parse
target/inputs/heightout of the request. - Get the contract wasm — from
wasm_dirif pinned there, else espoessentials.get_alkane_wasm— and cache the compiled module (TTLwasm_ttl_seconds; bytecode is immutable per alkane, so this is safe to hold). - Run
__executeagainst a stub host whose storage reads come from espoessentials.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 the0xffffffplaceholder runs at espo's tip height — kirby polls it every second, so that's free and it matches the storage source exactly. - A
__call/__staticcall/__delegatecallruns 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.delegatecallkeeps the caller's storage identity; astaticcall's writes are discarded. __balanceis served from espoget_alkane_balances(alkanes keeps balances in a global index, not in contract storage), with a call's incoming alkanes credited to the callee first — ascredit_balancesdoes before execution.- Calls to block
800000000aren'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. - 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.
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.
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.
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();{
"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).
cp config.example.json config.json # then fill in your two endpoints
./run.sh # builds if needed, then runsconfig.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.
- 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.
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.
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.
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).
{ "method": "kirby_simulateblock", "params": [{ "height": "0", "txs": [ /* alkanes_simulate requests, in order */ ] }] } // → { "results": [ /* one alkanes_simulate response per tx, in order */ ] }