Myotis is a trustless Ethereum wallet engine — a full participant in Ethereum's peer-to-peer networks that answers a wallet's requests with cryptographically verified data, with no trusted RPC provider in the loop. It speaks devp2p on the execution layer (discv4 discovery, RLPx encrypted transport, eth/66-71 (eth/66-69 on the Java engine), and snap/1 or snap/2 state proofs) and libp2p on the consensus layer (a beacon-chain light client), and verifies every byte against sync-committee BLS signatures back to beacon-chain finality.
Myotis runs on mobile, on desktop, and as a library embedded in other apps. The apps: an Android app (minSdk 29), an iOS app, a desktop GUI app, and a desktop daemon/CLI for development. The embeddings: a Node.js native addon that Electron and Node hosts load to run the node invisibly in-process, the way they run other embedded nodes — the Freedom browser is integrating Myotis this way as an experimental tier for fully-P2P verified .eth resolution (PR #181) — and, on iOS, the MyotisKit framework over the engine's C ABI: iOS suspends backgrounded apps, so an iOS wallet embeds Myotis as a library and runs the node in its own process rather than talking to a separate node app. Prebuilt addon binaries for macOS/Linux/Windows ship with every release since v0.1.3; see Embedding: Node.js / Electron.
A built-in JSON-RPC server exposes a verified subset of the Ethereum API over HTTP, so an unmodified MetaMask — pointed at the phone — can read balances, simulate calls, estimate gas, suggest fees, and broadcast a real transaction, all served from locally verified state. Nothing is taken on a peer's word: account and storage reads carry Merkle-Patricia proofs against a beacon-anchored stateRoot; blocks, transactions, and receipts are verified against the header's transactionsRoot/receiptsRoot; eth_call/eth_estimateGas run in a local EVM over proof-served state. When a request can't be answered from verified data, it returns an error rather than falling back to a trusted source.
Status: End-to-end verified
sendworks on a real device — MetaMask renders the confirm screen from verified balances, fees, and a local gas estimate, then broadcasts the signed transaction over devp2p, with no proxy and no permissioned service. The remaining gaps are listed in Implementation Status.
There are two interchangeable engines behind the same zero-dependency API (:myotis-api). The original Java engine is built on Java 21 with the Tuweni libraries (RLP, SECP256K1, byte utilities), in-house SSZ and Merkle-Patricia verification, a BLS verifier (pure-Java Milagro, or native blst from rust/myotis-bls behind the BlsBackends seam), and an embedded Hyperledger Besu EVM; it ships JVM 17 bytecode where the Android consumer needs it. The Rust engine (rust/ Cargo workspace) reimplements the whole stack natively — discv4/discv5, RLPx, eth/66-71 (eth/70 partial receipts, eth/71 block access lists), snap/1 + snap/2, the beacon light client on blst, a revm-based EVM, ENS + CCIP-Read, multichain — and is the primary engine: the default selection, and ahead of the Java engine on features (the eth_getLogs log index, Tor routing, EIP-7702/state overrides in the EVM, the finalized tag on state reads). Hosts pick one per network via the :myotis-engines selector (myotis.engine=java|rust|auto, default auto — the Rust engine where it can serve, Java fallback; -Pengine=… on run tasks, a Settings toggle in the apps). Behavioral parity is pinned by shared conformance vectors and golden tests on both sides — see Engines. The shared UI is Kotlin + Compose Multiplatform (Android, desktop, iOS).
- Architecture — Describes the target design for how the library will obtain and cryptographically verify all Ethereum data without relying on JSON-RPC providers; not all parts are implemented yet.
- How Verification Works — The chain of checks behind every answer, from the embedded checkpoint and the sync committee's signatures down to SNAP-served Merkle-Patricia proofs, with what is not verified.
- Benefits — Explains why a trustless wallet matters and what risks centralized RPC providers pose to users.
- Implementation Status — Current implementation progress and what remains to be done.
- Readiness & Verified Head Age — When the node counts as synced and ready to answer queries, and what the "verified head age" on the Status screen means.
- Status Screen — Field-by-field guide to the Status screen: the readiness strip, banners, sync bar, every status row, and the actions below them.
- Read Statistics — The
read-statsshadow cache: what a state-read cache (per-block, storage-root-keyed, or stale-serve) would have saved on a real session, measured without serving anything from it. - Disk & Network Usage — Storage footprint of a fully synced client (peer caches, light-client snapshot — there is no on-disk block/header database) and bandwidth: initial sync, the daily cost of staying synced, and what sending a transaction costs.
- Re-Implementation Specification — The language-agnostic spec the Rust engine was built from: the trust model, wire protocols, verification ladder, engine API and the Rust-phase notes and plan (
06,07). - eth_getLogs: the log index — Design of the opt-in, verified log index behind
eth_getLogs(Rust engine); companions: verified bundles (proposed) and seeded histories (the one bounded trust carve-out). - Privacy & Tor — What the node leaks to peers today and the (feature-gated, experimental) Tor routing of account reads on the Rust engine.
- Glamsterdam readiness — How the Gloas light client and the Amsterdam EVM are handled (live on Sepolia since the 2026-10-06 fork; mainnet and Gnosis have no date yet).
- Dedicated light-client server — The design behind
rust/roost, the light-client server that keeps a free slot for wallets. - A dedicated snap server? — Analysis (nothing built): whether snap state reads can be served to thousands of wallets the way roost serves the light client — measured proof sizes and lifetimes, a front for a full node instead of a cache, unchanged trust, and the privacy cost of one operator seeing every wallet's addresses (onion service vs. Tor exit).
- Onion services on wallets — Analysis (nothing built): wallets serving each other light-client data through Tor onion services — reachability behind CGNAT without an IP, why it does not help snap, and why myotis's Ethereum-facing peer ids must stay secp256k1 while an overlay identity can be ed25519 (and then a peer id is an onion address).
- Base (L2) evaluation — Whether and how Base could be supported under the trust model: no sync committee, so the only anchor is Base's proposals on mainnet (TEE-attested, 29–75 min old in the 2026-11 sample, no ZK proofs posted in practice — and, since Denim's 200 ms blocks, too old for the 8192-block header-chain bound to reach the head), the weaker evidence available before one lands, the RPC landscape, and the cost of proving sequencing from L1 blob data.
- dApp browser & RPC interception — Whether the apps should embed a dApp browser (a dApp is only usable with a signer, and Myotis holds no keys), what a dApp's own RPC traffic looks like, how an embedded browser or an extension could redirect it to the engine, what the engine cannot serve of it, and the recommended path (an extension PoC first, a read-only viewer on mobile at most). Postponed by the owner on 2026-10-10: the Freedom browser's integration is good enough for now.
- Integrations and proofs of concept: Bee (Swarm) on Myotis (short version, headless on Linux), the RAILGUN PoC build (and its seed on Android), React Native (design), inbound connections (design), multichain.
- Historical: Optimisations & Limitations (the Java engine's Android tuning record), the SOLID and Clean Architecture reviews of the original modules, and the review follow-ups in docs/TODO.md.
The Android and desktop apps and the desktop daemon run an embedded JSON-RPC server (loopback-only 127.0.0.1 — mainnet on 8545, Gnosis on 8546, Sepolia on 8547, Chiado on 8548 by default; the apps let you change the port per network) that a same-device wallet talks to like any other Ethereum endpoint. (The iOS app carries the same listener for development, but iOS suspends backgrounded apps, so a separate wallet app cannot rely on it — on iOS a wallet embeds Myotis as a library instead.) Every method is answered only from cryptographically verified data; there is no trusted-RPC fallback in production (a dev-only upstream proxy exists purely to map what a wallet needs and is off in strict mode). When a request can't be served verified, the server returns a JSON-RPC error:
-
-32601— the method isn't served verified at all (the wallet can stop asking). -
-32000— the method is implemented but can't be answered right now (not synced, no snap peer, the head isn't beacon-anchored yet, an uncovered log-index range, or a state read /eth_callpinned to a block hash this node holds no block for — never verified here, not yet, or on a fork; see Block selectors — retryable). One exception, kept for geth compatibility: aneth_estimateGas,eth_calloreth_createAccessListwhose transaction cannot succeed within the caller's owngas, fee cap or funds answers-32000with geth's message verbatim (gas required exceeds allowance (N),insufficient funds for transfer,out of gas; a call that fails a check before it runs as geth's eth_call words it,err: intrinsic gas too low: have N, want M (supplied gas N)and likewise for the floor,insufficient funds for gas * price + value: …andmax fee per gas less than block base fee: …) — an answer about that transaction, which a retry only repeats. Likewise aneth_sendRawTransactioneither engine refuses before broadcasting it, because the sender cannot pay for it or its nonce is used (geth's txpool words:insufficient funds for gas * price + value: balance B, tx cost C, overshot D,nonce too low: next nonce N, tx nonce M). -
3—eth_call/eth_estimateGasexecuted over verified state and the contract (or the transaction being estimated) REVERTED: the standardexecution revertederror, with the raw revert payload inerror.dataand the decodedError(string)reason in the message when present. This is a verified chain answer (not retryable) — wallets rely on it, e.g. MetaMask's ERC-165 token-standard probe expects a revert on plain ERC-20s, and a doomed transaction's estimate shows its actual revert reason instead of "node not synced".eth_createAccessListnever answers3: a reverting run keeps its list, and the revert rides in the result'serrorfield next to it, as geth serves it. -
-32602— the request's parameters are malformed, or valid but unsupported by this node, and no retry will change that: answering without them would return a well-formed result to a different question than you asked. Every parameter of every verified method is applied or refused this way (#366):- malformed or out-of-spec values — more arguments than the method takes or
paramsthat is not an array (geth'stoo many arguments/non-array args), an address that is not 20 bytes, a transaction or block hash that is not 32, non-hex data, an empty raw transaction, a non-booleanfullTransactionsflag, an index oreth_feeHistorycount that is not a quantity, a count below 1, reward percentiles that are not numbers in [0, 100], non-decreasing, at most 100, or a missingeth_feeHistorynewest block (an empty percentile list asks for norewardcolumn, as in geth, and a count above 1024 is clamped as geth clamps it), and aneth_getLogsfilter that is malformed, namessafeor a block hash, has no address, or puts an explicitfromBlockabove an explicittoBlock(refused even while the node is paused or syncing); - block selectors this node cannot apply (see Block selectors below) —
safe,earliest, block 0, an empty string, bare decimal digits or a JSON number, a block hash or an EIP-1898 object where the method takes only a number (eth_getBlockByNumber,eth_feeHistory, the by-number compat reads), the genesis block's hash on the state reads,eth_call,eth_estimateGasandeth_createAccessList(#641),finalizedon the Java engine (which would answer it from the head), and a number — or the hash of a block — pinned more than 64 blocks behind the verified head on the state reads,eth_call,eth_estimateGasandeth_createAccessList(unless it is at or above a head this node itself reported in the last 15 minutes — a head that went back and forth is the node's glitch, answered with the retryable-32000); eth_call/eth_estimateGas/eth_createAccessListfields — a block override (params[3]: any value butnullor{};eth_createAccessListtakes three parameters, so a fourth istoo many arguments), a state override (params[2]) on an engine that cannot apply it (the Rust engine applies state overrides; the Java engine does not); a transaction object that is contradictory (a type-4 request without anauthorizationList, a tip above its fee cap,dataandinputthat differ), names another chain inchainId, or is a blob transaction; anauthorizationList/accessListor contract creation on the Java engine, andeth_createAccessListitself on the Java engine (it builds no access lists); aneth_calloreth_createAccessListwhosegas, above the node's 30 M call budget, was capped to it and ran out there;eth_sendRawTransactionbytes that are no transaction this node relays (#360), on both engines and before anything is broadcast: not a strict decoding of a legacy or type 0x1 / 0x2 / 0x4 transaction (its type's exact fields, canonical RLP, nothing trailing, access-list and authorization entries as geth decodes them — so the hash a send returns is always the decoded transaction's own), signature values a peer refuses (invalid transaction v, r, s values, EIP-2's lowsamong them), a signature that recovers no sender (invalid sender: …), a blob transaction (type 0x3), which may not be pushed in aTransactionsmessage, or a transaction signed for another chain (invalid sender: invalid chain id for signer: have X want Y, #665; a pre-EIP-155 legacy transaction names no chain and is sent);- an engine's permanent refusal — a block past a fork the engine cannot execute (Amsterdam on Sepolia on the Java engine, whose Besu 26.4 cannot price it; the Rust engine's revm does), and any read the engine itself marks permanent.
Fall back to a request without the field, or use an upstream that applies it.
- malformed or out-of-spec values — more arguments than the method takes or
-
-38010to-38026,-32603—eth_simulateV1only: geth's own simulation errors, with geth's codes and words (#716): a nonce too low or too high (-38010/-38011) or a fee cap below the base fee (-38012) undervalidation, a gas limit below the intrinsic cost (-38013), a balance short ofgas × fee cap + value(-38014), the block's gas limit reached (-38015), block numbers or timestamps out of order (-38020/-38021), an init code above EIP-3860's limit (-38025), more blocks or calls than geth allows (-38026). Each is an answer about the request: no retry changes it. Inside a successful answer, a call that reverted carrieserror.code3and one that halted-32015, as in geth. -
-32600— the request object itself is invalid:jsonrpcis not"2.0",methodis not a string, theidis not a string, number or null (answered with anullid), or a batch carries more than 1000 requests (refused whole, as geth refuses it). A request without anidis a notification: it is served but never answered, and a batch of notifications gets an empty body.
Security: the server binds loopback only by default — the wallet is a same-device client, and the endpoint is unauthenticated with no TLS or rate limiting (and
eth_sendRawTransactionrelays whatever signed bytes it's handed), so it is deliberately not reachable from other devices. Exposing it on a routable interface would require an explicit, opt-in change.
Connecting MetaMask: MetaMask runs on the same device as the node. Add a custom network pointing at http://localhost:8545 (chain id 1 for mainnet). The desktop daemon serves the same verified JSON-RPC (mainnet on 127.0.0.1:8545) and additionally exposes the verified operations over its CLI/IPC socket (see Query commands below).
Web pages and browser-extension wallets: by default no web page may use the node — a page open in a browser on the same device is refused until you allow its origin under Settings → Web page access (the apps show a refused page on the Status screen with an Allow button, and raise a notification for it: on Android with Allow in it, on desktop through Notification Center on macOS and the tray icon elsewhere). The MetaMask extension in Chrome or Firefox counts as a site and needs allowing once; MetaMask Mobile and other native wallet apps are not web pages and always work. Details and the rules the gate applies: docs/web-page-access.md.
The authoritative list is VERIFIED_METHODS in jsonrpc-server's RpcRouter.kt; this
table mirrors it. myotis_rpcCoverage on a running node reports what has
actually been asked and answered.
| Method | How it's verified |
|---|---|
eth_chainId, net_version |
from config |
eth_blockNumber |
beacon optimistic-head execution block number |
eth_syncing |
straight from the beacon light client: false once SYNCED, otherwise a syncing object with zero bounds (the verified surface has no block-download notion and serves no chain-state reads before SYNCED; config and utility methods answer regardless) |
eth_getBalance, eth_getTransactionCount, eth_getCode, eth_getStorageAt |
snap (snap/1 or snap/2) Merkle-Patricia proof against a beacon-anchored stateRoot (absent accounts/slots proven via exclusion proof — a verified zero, not a guess) |
eth_getProof |
Rust engine only: the account proof and one proof per storage key (EIP-1186, geth's result shape) — the very snap proofs the state reads above verify, trimmed to the root-first path each key's walk visits, all verified at one beacon-attested state root. Stricter about its block than the other state reads: a proof carries no block number, so a client checks it against the stateRoot of the block it asked for, and a proof is served only at a root this node holds attested for exactly that block — latest/pending (the verified head), finalized, or the number of one of the last 128 heads this node held that the current head builds on, the current one included (so a number just read from eth_blockNumber, or from a light client of the caller's own, still works; a head a sibling chain replaced is never served for its number: each head's proven parent hash links it to the current head, and where a skipped light-client update breaks that chain one header window anchored at the head decides), or of the finalized block — or the hash of one of those blocks (ABI 47), which the engine places together with its attested root in one read, so a reorg can never pair the hash with another block's root. Any other number is refused (-32602 below the head, the retryable -32000 above it), and any other hash is refused like its number: -32602 for a hash the node places at or below the head (a block it holds no attested root for, which it never will once passed), the retryable -32000 for one it cannot place (ahead of the head, older than its window, or on a fork) — never answered from head state. At most 1024 storage keys; an absent account carries the empty account's code hash and storage root (reth's form; geth answers zero hashes); refused (-32602) while Tor routing is on. The Java engine refuses it (-32602). It adds no security for a client that also takes the block header from this node — it is for clients that check proofs against a root of their own (a Helios-style light client) |
eth_getAccount |
Rust engine only: geth's {codeHash, storageRoot, balance, nonce} — the account leaf of the eth_getProof proof, so every field comes from one state root, at exactly the block asked for (eth_getProof's pin rules: a block whose state root this node holds attested). An absent account answers the empty account, as eth_getProof proves it. The Java engine refuses it (-32602) |
eth_call |
local EVM over proof-served state — revm on the Rust engine, Besu on the Java engine — with a multi-hop speculative prefetch loop batching the SLOAD round-trips on both, and hedged state fetches across peers on the Rust engine. The whole transaction object is applied as geth applies it: from, to, data/input, value and the block selector; gas is the call's limit (running out of it is geth's out of gas); a fee must reach the base fee, is charged before the call runs and is what GASPRICE reads, and — fee or not — the sender must hold gas × fee cap + value. The Rust engine also applies accessList, authorizationList, a state override (params[2]) and contract creation (an absent to runs the init code); the Java engine refuses them (-32602), as both engines refuse blob fields and block overrides. A revert is error 3 with the payload |
eth_estimateGas |
local EVM: geth's search for the lowest gas limit at which the transaction succeeds, plus a 15% buffer (and at least the EIP-7623 calldata floor), over the whole transaction object: EIP-7702 authorizationList (applied as a mined transaction applies it — an invalid tuple is skipped), accessList, the gas cap, the fee fields (what GASPRICE reads, and geth's cap of what the sender can pay for), nonce, type, the block selector and a state override — the Rust engine applies all of them; the Java engine applies gas, fees and the block and refuses the rest (-32602). The answer never exceeds the caller's gas (without one, the block's gas limit), and never falls below what the transaction draws when run as simulated; a plain transfer to an EOA short-circuits to 21000; a reverting tx returns an error, never a number |
eth_createAccessList |
local EVM, Rust engine only: the EIP-2930 access list the transaction touches, built as geth builds it — the run is traced for every address and storage key it reads or writes, leaving out what every transaction warms anyway (the sender, the recipient, the precompiles; the recipient's own slots are in), then made again with the list applied until the list stops changing. The result is geth's {accessList, gasUsed} (the list sorted by address and key, the gas what the run made with the list used), with the run's own revert or halt in error next to the list rather than a code-3 error. Takes the transaction object eth_call takes, with the same checks and the same -32000 answers for a request that cannot run within its gas, fee cap or funds, the block selector, and — as reth, not geth — a state override as a third parameter. The Java engine refuses it (-32602). One deliberate difference from geth: a request without fee fields runs fee-less, as eth_call runs it, where geth fills in a fee from its own tip oracle and then holds the sender to it |
debug_traceCall |
Rust engine only (#716): the run eth_call makes for the transaction object — the same request checks, block selectors and state overrides (stateOverrides in the trace config) — seen through geth's callTracer (onlyTopCall, withLog) or prestateTracer (disableCode, disableStorage, includeEmpty), with geth's output shape: a run that reverts or halts is a trace carrying the failure, with geth's error strings and decoded revert reason. The call tree matches a full node's trace frame by frame, gas included (a recorded mainnet EIP-7702 shield among the tests). Applied or refused: geth's default opcode logger, any other tracer, diffMode, a non-empty blockOverrides and any unknown option are refused with -32602; timeout is accepted (this node's own request bound applies). Without a gas, the top frame's gas is this node's 30 M call budget where geth reports its RPC gas cap. The Java engine refuses it (-32602) |
eth_simulateV1 |
Rust engine only (#716): blocks of calls simulated on top of the selected block, as geth simulates them, over proof-served state — every call's state changes carry to the next call and the next block. Per block: blockOverrides (number, time — gaps filled with empty blocks, 12 s apart — gasLimit, feeRecipient, prevRandao, baseFeePerGas, blobBaseFee, beaconRoot, withdrawals) and stateOverrides; the system calls (EIP-4788, EIP-2935 before the calls, EIP-7002 and EIP-7251 after). Per call geth's returnData, logs, gasUsed, maxUsedGas, status and error; traceTransfers (ether transfers as ERC-7528 logs), validation (nonces, fee caps against the base fee computed from the parent, balances — each failure geth's error code, -38010 to -38026) and returnFullTransactions. geth's limits: 256 blocks, 5,000 calls per block, 10,000 in all; every call of a request draws on one 30 M gas budget, this node's call budget in the role of geth's RPC gas cap. Matches a full node's answers field by field, roots and transaction hashes included (rust/testdata/evm/simulateV1-golden.json). What this node does not compute is null, never made up: a simulated block's hash and stateRoot (its post-state root needs trie nodes no proof carries), so a later block's parentHash and every blockHash; excessBlobGas, requestsHash and size. A call that READS one — BLOCKHASH of a simulated block, the EIP-2935 slot holding it, BLOBBASEFEE without the blobBaseFee override — is refused with -32602, as are Amsterdam blocks, withdrawals on Gnosis, a state override of a precompile, movePrecompileToAddress and any unknown key. On Gnosis the base fee a call pays is credited to the chain's fee collector. A simulation starts from exactly the block it names: a number must be the verified head's or the finalized block's (a block behind both is refused with -32602, one ahead of the head is retryable), never the near-head window eth_call serves from the head, since the answer is built on its base. The Java engine refuses it (-32602) |
eth_gasPrice, eth_maxPriorityFeePerGas, eth_feeHistory |
base fee from verified headers; priority-fee tips from block bodies verified against transactionsRoot (+ receipts vs receiptsRoot for the gas-weighted percentile reward walk) |
eth_blobBaseFee |
EIP-4844's fake_exponential over the verified head block's own excessBlobGas, with the update fraction of the fork its timestamp falls in (EIP-7840's blob schedule — Prague, BPO1, BPO2 on mainnet and Sepolia; Gnosis's own fraction and 1 gwei minimum, which Chiado shares), cross-checked against full nodes in every era. A chain without a schedule here is refused (-32602), never answered with a guessed fraction |
eth_getBlockByNumber, eth_getBlockByHash |
verified header window anchored to the beacon head; tx hashes checked against transactionsRoot (no snap peer required); an unknown or non-canonical hash answers null |
eth_getBlockTransactionCountByNumber, eth_getBlockTransactionCountByHash, eth_getTransactionByBlockNumberAndIndex, eth_getTransactionByBlockHashAndIndex, eth_getUncleCountByBlockNumber, eth_getUncleCountByBlockHash, eth_getUncleByBlockNumberAndIndex, eth_getUncleByBlockHashAndIndex |
derived from the same verified block serve (count or element read out of the verified block); post-Merge blocks have no uncles, so the uncle reads answer 0 / null from a verified block, not a stub |
eth_getTransactionReceipt, eth_getBlockReceipts |
scans the recent verified block window; receipts verified against receiptsRoot (handles eth/69 bloomless receipts by recomputing the bloom) |
eth_getTransactionByHash |
mined txs from the verified block window; locally-broadcast txs served as pending from a sent-tx cache; sender recovered from the signature (legacy + EIP-2930/1559/4844/7702) |
eth_getRawTransactionByHash, eth_getRawTransactionByBlockNumberAndIndex, eth_getRawTransactionByBlockHashAndIndex |
the transaction's raw consensus bytes (the EIP-2718 envelope for a typed one) from a block body verified against transactionsRoot, found as eth_getTransactionByHash and eth_getTransactionByBlock*AndIndex find the transaction; by hash also this node's own unmined broadcasts, and the bytes are checked to hash to the transaction asked for. Same window limits; null for anything else. Both engines, every host |
eth_getLogs |
served only from the log index: an opt-in, per-network index of the contracts you choose, every log verified against receiptsRoot over devp2p. A range the index has not covered is refused with -32000 (the message says how far coverage reaches), never answered with a misleading []. Historical coverage has to be built — a backfill to the contract's deployment block — and can be bundled: build once on an always-on daemon, export-logindex the portable chain-tagged .db, import it in the app. Rust engine only, and served by the JVM hosts (Android, desktop app, daemon) — the iOS app's RPC backend does not wire eth_getLogs yet, so it answers -32000 there. |
eth_newFilter, eth_newBlockFilter, eth_getFilterChanges, eth_getFilterLogs, eth_uninstallFilter |
the polled filters viem, ethers and web3.js fall back to without a WebSocket (#714), held by the JSON-RPC server. A block filter answers the verified head hashes since the last poll — the heads eth_blockNumber reports, every block in between included; a log filter answers from the log index with eth_getLogs's criteria, so it has eth_getLogs's limits (Rust engine only, not the iOS app — block filters work everywhere). A log filter is created only when eth_getLogs over its own criteria answers: an address the index does not watch, or a range it has not covered, is refused at eth_newFilter with eth_getLogs's error, never accepted and then answered with []. fromBlock/toBlock take latest, pending, a 0x number and (fromBlock only) earliest; finalized, safe and blockHash are refused (-32602). Each poll walks the verified chain down from the head, checking each block against its child's parentHash, to the blocks it delivered last; a reorg delivers the orphaned blocks' logs again with removed: true before the new branch's, from the last 32 delivered blocks. A reorg deeper than that, a head more than 256 blocks past the last poll, or more than 10,000 logs in one poll drops the filter with -32000 and the reason (re-create it and backfill with eth_getLogs). A poll that cannot read the new blocks, or whose logs the index has not caught up with, answers no changes and keeps them for the next poll — delayed, never skipped — and says so with -32000 once that has lasted two minutes. A filter unpolled for 5 minutes expires (-32000 filter not found, geth's words); at most 64 are installed at once; pausing the node drops them all. eth_newPendingTransactionFilter stays -32601: this node has no mempool view. |
eth_sendRawTransaction |
gossips the user-signed bytes to devp2p peers and returns the hash (Myotis never signs — the wallet does). Bytes that are no transaction — undecodable under a strict reading of their type, no sender recovered, or a blob transaction — are refused with -32602 and never broadcast (#360), and so is a transaction signed for another chain (#665). Both engines then check what geth's txpool checks: a transaction whose sender cannot pay value + gas × fee, or whose nonce is already used, is refused with geth's -32000 message and never broadcast — but only when the sender's balance and nonce were proven against a fresh head's own state root (the Rust engine's optimistic head, the Java engine's anchored head — which judges the balance only when it is the optimistic head itself); otherwise it is sent as before. |
eth_accounts |
the node holds no keys: exactly [], a verified-grade constant |
net_listening, net_peerCount |
true (the discovery listener is live whenever the node runs); the peer count comes from the node's own status snapshot, never a fabricated zero |
web3_clientVersion, web3_sha3 |
static identifier Myotis/verified-light-client; local keccak-256 — no chain data involved |
Any other method returns -32601 (not served verified); a method from the table that
cannot be answered right now returns the retryable -32000 instead — the full
error-code contract is the list at the top of this section.
Node introspection (myotis_*). Answered locally, bypassing the verified backend, so a
myotis-aware client can poll them before the node is synced or has peers:
myotis_status and myotis_beaconStatus (the JSON-RPC twins of the daemon's status /
beacon-status commands), myotis_rpcCoverage (which methods have been requested and how
they were answered), and myotis_pause / myotis_wakeup (the host's background/foreground
hooks).
Block selectors. One reading for every method that takes a selector (#366): a tag, a 0x-prefixed hex number, and — where geth takes a BlockNumberOrHash (the state reads, eth_call, eth_estimateGas, eth_createAccessList, eth_getBlockReceipts) — a 32-byte block hash or EIP-1898's object form ({"blockNumber": …} is that number; {"blockHash": …} that hash). Anything else is refused with -32602, never guessed: an empty string (only an absent or null selector means latest), bare decimal digits (one engine reads them as decimal, the other as hex), JSON numbers, unknown or mixed-case tags. latest and pending resolve to the verified (optimistic) head — pending with this node's own unmined broadcasts in eth_getTransactionCount, as it has no view of the public mempool. safe is refused: neither engine tracks the safe (justified) head, and both used to answer it from the head — another block. earliest and block 0 are refused (no genesis state or blocks are held). finalized is applied on the Rust engine: eth_call, the block and receipt reads and eth_feeHistory run against the beacon-finalized block (ABI 30), and the state reads prove against the finalized state root (ABI 32; best effort, since execution clients keep only on the order of 128 recent states and finality trails the head by two epochs — a finality delay makes such a read the retryable -32000 until finality catches up). The Java engine would resolve finalized to its head, so the router refuses the tag there (-32602) on every method. A number-pinned read (wallets pin every read to the block they just saw) is served from the head's state when the number is within a small window of it (64 blocks below to 16 above) — the near-head trade-off; exact-block execution is #382. A number further behind is refused with the permanent -32602 before any engine is asked, on both engines — unless it is at or above a head this node itself answered eth_blockNumber with in the last 15 minutes, which stays the retryable -32000 (a wallet pins the number it was given, and a node head that swung back is not the wallet's invalid params); a number ahead of the window answers the retryable -32000 (the head may yet reach it). A block hash on the state reads, eth_call, eth_estimateGas and eth_createAccessList (EIP-1898 clients pin every read to the hash of the block they just saw) is resolved to the canonical block it names and served under exactly the number's rules (#641): the same window, the same reported-head exception, the engine handed that block's number — so a hash inside the window answers what its number answers, and one further behind is refused with the permanent -32602 like a number. The engine resolves the hash header-only (ABI 47), and only to a block the chain carries at that height as of the verified head: the beacon-attested head and the finalized block at once with no network round trip, any other hash in one header window anchored at the head, 64 blocks deep (or down to the number of an older block this node served by that hash, up to 512 back). No block body is fetched, and the block need not have been read here before. A hash it cannot place — not reached yet, older than that window, or on a fork — is declined with the retryable -32000, naming the hash: this node cannot tell a block it has not verified YET (a client a block ahead of it pins the hash it was just handed, which this node holds seconds later) from one it never will, and a number ahead of the window is retryable for the same reason; a genesis hash the node can place is refused for good like block 0 (on a live network neither engine's map holds genesis, so in practice it is declined like any unknown hash), and a malformed hash is a parameter error. eth_getBlockByHash and eth_getBlockReceipts serve a hash directly. Block reads (eth_getBlockByNumber, the receipt scans) look back up to 512 blocks below the head. Details: Readiness & Verified Head Age → Block tags.
- Java 21+
- Gradle (wrapper included)
- For the Rust engine: rustc/cargo stable (at least the workspace
rust-versioninrust/Cargo.toml) — optional for the JVM hosts, required for the Android app and the packaged desktop installers; see Engines for the per-target toolchains.
# Build all modules (includes tests)
./gradlew build
# Compile only (skip tests)
./gradlew compileJava
# Run tests
./gradlew testMyotis runs in four app forms: the Android app, the iOS app, and the desktop app (Compose GUIs over the shared :ui, running the wallet node with the verified JSON-RPC server), and the desktop daemon/CLI (the same engine for development, with a CLI/IPC command surface). All of them run the full devp2p + libp2p stack, the beacon light client, and the local EVM; on iOS the node runs on the Rust engine (the JVM engine never runs there). A fifth form is an embedding rather than an app: the engine as a Node.js addon for Electron/Node hosts — and on iOS as the embeddable MyotisKit framework — see Embedding: Node.js / Electron.
# Build + install the debug app on a connected device
./gradlew :android-app:installDebugThe debug build is its own app, Myotis Debug (com.jaeckel.ethp2p.android.debug, amber icon), signed with the committed and therefore public debug key. It installs next to the release app, com.jaeckel.ethp2p.android, which is signed with the release key that CI holds; neither can update the other. Every release ships both APKs; see docs/android-release-signing.md.
The app runs the node as a foreground NodeService (Start/Stop in the UI). Once it reaches SYNCED, the JSON-RPC server is live on 127.0.0.1:8545; point an on-device MetaMask at http://localhost:8545 (custom network, chain id 1). The app persists the sync snapshot, the known-state-root window, and light-client-capable peers, so warm restarts reach SYNCED in ~10 s. minSdk 29.
The iOS app builds on macOS only (Xcode 26+, plus the Rust iOS targets: rustup target add --toolchain stable aarch64-apple-ios aarch64-apple-ios-sim). On iOS the node always runs the Rust engine over its plain C ABI — the JVM engine never runs there. :app-ios bundles the shared :ui and the engine into a Kotlin/Native framework (MyotisKit); the app shell is the Xcode project in ios-app/ (regenerable with xcodegen generate).
# Kotlin/Native framework for the arm64 simulator (builds the Rust engine first)
./gradlew :app-ios:linkDebugFrameworkIosSimulatorArm64
# Build the app from the Xcode shell
cd ios-app && xcodebuild -project Myotis.xcodeproj -scheme Myotis \
-destination 'platform=iOS Simulator,name=<device>' buildOn iOS the app form is a development host more than an integration point: iOS suspends backgrounded apps, so the app's loopback JSON-RPC listener (foreground-only) can't serve a separate wallet app the way the Android and desktop nodes can. The supported iOS integration is embedding — a wallet links the MyotisKit framework (or the engine's C ABI directly) and runs the node in its own process. Releases ship a prebuilt MyotisKit.xcframework.zip (device arm64 + arm64 simulator, + .sha256) so a host can drop it in without a local Kotlin/Native build.
:app-desktop is the Compose-Multiplatform desktop GUI — the same :ui NodeScreen the Android app hosts, running the engine in-process (the Rust engine by default, the Java engine as fallback or by choice). It shows the Status, Query and Settings tabs — plus Logs and Index under Settings → Expert mode — and serves the same verified JSON-RPC.
# Run from source (dev loop) — starts the GUI, compiling the Rust engine first
./gradlew :app-desktop:run
# Pick the engine explicitly: auto (default) | rust | java
./gradlew :app-desktop:run -Pengine=javaBuild a native installer for the host OS with jpackage. jpackage is host-OS-bound: the .dmg can only be produced on macOS, the .deb only on Linux and the .msi only on Windows (CI builds each on its matching runner — desktop-dmg.yml / desktop-linux-deb.yml / desktop-windows-msi.yml); locally you get the format for your OS. The .msi additionally needs WiX Toolset 3 (candle.exe/light.exe) on the PATH — JDK 21's jpackage drives WiX 3, not WiX 4+.
The Windows installer is x64-only and is not attached to releases yet: CI installs, launches and uninstalls it on a Windows runner, but it has not been tried on a real Windows machine. Pushes to main upload it as a workflow artifact (myotis-desktop-msi-x64-<sha>), and the workflow can be dispatched from the Actions tab for any branch. It installs per user (no admin prompt) under %LOCALAPPDATA%\Myotis; Windows 11 on ARM, including a Windows VM on an Apple Silicon Mac, runs it through its x64 emulation. A newer version installs over an older one, but Windows Installer treats two builds of the same version (say, two main commits between releases) as one product, so uninstall the earlier build (Settings → Apps) before installing the next.
# macOS → build a .dmg (output under app-desktop/build/compose/binaries/main/dmg/)
./gradlew :app-desktop:packageDmg
# Linux → build a .deb (output under app-desktop/build/compose/binaries/main/deb/)
./gradlew :app-desktop:packageDeb
# Windows → build an .msi (output under app-desktop/build/compose/binaries/main/msi/; needs WiX 3)
./gradlew :app-desktop:packageMsi
# Or just the right format for whatever OS you're on
./gradlew :app-desktop:packageDistributionForCurrentOS
# Run the packaged app straight out of the build dir (no installer step)
./gradlew :app-desktop:runDistributableOn macOS, :app-desktop:run is a plain java process with no app bundle: the menu bar carries the app's name, but Cmd-Tab still lists it as "java" (macOS names a bundle-less process after its executable), and notifications go through the tray icon. runDistributable runs the real bundle, which carries the app's name, icon and notifications everywhere.
The bundle embeds a full Java 21 runtime (the :networking/:myotis-evm backend ships Java-21 classes and reaches JDK modules reflectively, so the whole module graph is included). Packaged builds (dmg/deb/msi/runDistributable) bundle the Rust engine as an app resource — packaging fails loudly if cargo isn't available, so an installed app can always switch engines. Logs roll under ~/.myotis/logs; the app's log level comes from the myotis.log.level JVM system property (default INFO). Each JSON-RPC call gets a one-line summary at INFO; the request/response bodies (size-capped, unredacted: they include addresses and signed transactions) are logged only at DEBUG — pick DEBUG on the Logs-tab chip, or launch with myotis.log.rpc.level=DEBUG. Note that a -D flag on a ./gradlew :app-desktop:run command line reaches the Gradle JVM, not the app — for a packaged app, edit the java-options in the bundle's generated Myotis.cfg.
Myotis also runs inside other applications as a Node.js native addon: rust/myotis-node is a napi-rs binding over the engine's C ABI (the same seam the iOS host consumes). A Node or Electron host loads myotis-node.node, starts the node in-process, and gets verified reads back as ordinary Promises — the engine's blocking calls run on the libuv thread pool. Every release since v0.1.3 ships prebuilt addon binaries (macOS arm64/x64, Linux arm64/x64, Windows x64) plus a SHA256SUMS manifest.
const myotis = require('./myotis-node.node'); // ESM: createRequire(import.meta.url)
myotis.init(); // ABI handshake
const h = myotis.create('mainnet', '/path/to/data-dir');
// or, to recover a STALE_ANCHOR install from a checkpoint the host authenticated itself
// (ABI >= 26): myotis.createWithCheckpoint('mainnet', freshDir, blockRoot, headerSlot)
myotis.start(h);
// once statusJson(h) reports beaconState === 'SYNCED', elReaderAvailable, and
// snapServingPeers > 0 (ABI >= 31: a pooled peer that can answer at the anchored
// head — a pool of still-syncing peers keeps snapPeers > 0 while every read fails):
const ens = JSON.parse(await myotis.resolveEnsJson(h, 'vitalik.eth'));This is how the Freedom browser is integrating Myotis (PR #181): an invisible background node — alongside the browser's Swarm and IPFS nodes — behind an experimental tier that serves fully-P2P verified .eth resolution with no prover service and no RPC provider in the loop.
For Swift hosts that embed the engine without the Kotlin/Native MyotisKit framework, rust/build-xcframework.sh packages the engine's plain C ABI as a static MyotisEngine.xcframework (device arm64 + fat simulator slice, header + Clang modulemap included — import MyotisEngine from Swift). Releases ship the zip + SHA256 alongside the napi addons, and CI builds the iOS targets on every rust/ change so they stay green.
The host contract is the same as everywhere else: gate on myotis_init() returning the header's MYOTIS_ABI_VERSION, run the blocking verified reads off the main thread, free every returned string with myotis_string_free, and treat myotis_pause/myotis_resume as the scenePhase background/foreground hooks (warm resume is ~10 s back to SYNCED). Wait for statusJson to report beaconState == "SYNCED" and snapServingPeers > 0 before attempting reads — right after sync the pool can hold only peers that are still syncing themselves (snapPeers > 0, yet every read fails until one of them covers the anchored head, #465). A host that knows a serving execution node can hand it to the engine with myotis_set_boot_enodes (ABI ≥ 31, a JSON array of enode:// URLs, applied or refused as a whole) to shortcut a cold start; the engine ships no mainnet seed list of its own.
One caveat: an app that already links another Rust staticlib (another embedded node) must not add this xcframework next to it — two Rust staticlibs in one binary collide on the runtime/allocator. Build a single aggregator staticlib crate that depends on both engines as rlibs and re-exports their C symbols instead (one std/allocator/tokio/libp2p). That is how the Freedom iOS browser embeds Myotis alongside its Swarm and IPFS nodes: freedom-mobile-ffi.
The daemon discovers peers, maintains connections, and listens for CLI commands on a Unix domain socket (/tmp/ethp2p.sock); it exposes the verified operations as CLI commands (get-account, get-storage, resolve-ens, …) alongside the same verified JSON-RPC server the apps run (mainnet on 127.0.0.1:8545). A client invocation sends a single command to the running daemon and exits. The daemon has no settings screen, so which web pages may use its JSON-RPC listener is a run property: -PwebAccess=off|all|<origin>,<origin>… (default: none — wallet apps and curl are unaffected; see docs/web-page-access.md).
# Mainnet
./gradlew :app:run
# Custom port (default: 30303)
./gradlew :app:run -Pport=30304The daemon runs in the foreground. It discovers peers via discv4 (Kademlia DHT), establishes RLPx encrypted connections, and performs eth protocol handshakes. A beacon chain light client syncs finalized state roots from the consensus layer.
Gnosis Chain (chainId 100) is a supported network with its own consensus
layer (Gnosis Beacon Chain — 5s slots, 16 slots/epoch). Select it with
-Pnetwork=gnosis:
# 1. Refresh the trusted checkpoint (weak-subjectivity anchor) for Gnosis.
# Do this before the first sync so the light client bootstraps from a recent
# finalized root that peers still serve.
./gradlew refreshCheckpoint -Pnetwork=gnosis
# 2. Start the Gnosis daemon.
./gradlew :app:run -Pnetwork=gnosisEach network is fully isolated: the Gnosis daemon uses its own IPC socket
(/tmp/ethp2p-gnosis.sock), lock file, and peer/sync caches (suffixed
-gnosis). Because of that, every client command must also carry
-Pnetwork=gnosis so it talks to the right daemon:
./gradlew :app:run -Pnetwork=gnosis -Pargs=status
./gradlew :app:run -Pnetwork=gnosis -Pargs=beacon-status # SYNCING → CATCHING_UP → SYNCED
./gradlew :app:run -Pnetwork=gnosis -Pargs=peers
./gradlew :app:run -Pnetwork=gnosis -Pargs="get-account 0x<address>"
./gradlew :app:run -Pnetwork=gnosis -Pargs=stopTo run Gnosis alongside mainnet, host both in one daemon process — each
network gets its own ports, lock file and IPC socket, and client commands still
pick the network with -Pnetwork:
./gradlew :app:run -Pnetwork=mainnet,gnosisTwo separate daemons also work, as long as the second one gets a different EL
port (mainnet keeps 30303): ./gradlew :app:run -Pnetwork=gnosis -Pport=30304.
One caveat for that form: a single-network daemon binds discv5 to UDP 9000
whatever the network, so a second Java-engine daemon beside mainnet fails
that bind and carries on without CL discovery (pinned CL peers and the cache
only); the default auto/Rust engine picks its own discv5 port and is
unaffected. The one-process form avoids the collision entirely.
Note: verified-state queries (
get-account/get-storage) require an active EL peer that servessnap(snap/1 or snap/2). Public Gnosis nodes are fewer and busier than mainnet's, so the light client may reachSYNCED(beacon trust anchor ready) before a snap-serving peer is held — retry oncepeersshows snap-capable connections, or let the peer cache warm over runs.
Chiado (chainId 10200) is Gnosis Chain's testnet, on the same beacon preset (5 s slots, 16 slots/epoch) with its own beacon chain and trust anchor:
./gradlew refreshCheckpoint -Pnetwork=chiado # publicnode + the Gnosis team's endpoint
./gradlew :app:run -Pnetwork=chiado
./gradlew :app:run -Pnetwork=chiado -Pargs=beacon-statusIts JSON-RPC port is 8548 (EL 30306, discv5 9003). Two things differ from Gnosis:
no light-client servers are pinned yet (there is no roost instance for Chiado,
and no census has confirmed public ones), so a cold start finds its CL peers
over discv5 from the Chiado bootnodes alone and can take longer to bootstrap;
Chiado has no name service, so resolve-ens and friends are refused there; and
its weak-subjectivity bound is ONE period (~11.4 h — its ~2.6k-validator set
churns fast), so a shipped Chiado anchor is usually stale for a fresh install:
refresh the checkpoint first, or accept the STALE_ANCHOR consent.
# Mainnet
./gradlew :app:run -Pargs=stop
# A specific network (e.g. Gnosis)
./gradlew :app:run -Pnetwork=gnosis -Pargs=stopAll commands are sent to the running daemon via IPC. Responses are JSON.
Most commands depend on different parts of the stack being up. After starting the daemon, wait for the sub-system you need before issuing queries. The table is also the command reference — the daemon hosts either engine, and a few commands exist on one engine only:
| Command | Engine | Requires |
|---|---|---|
status, beacon-status, pause, resume, accept-stale-anchor, read-stats, stop |
both | daemon running (beacon-status progresses SYNCING → CATCHING_UP → SYNCED) |
peers |
both — the Rust engine returns empty peer lists; use the counts in status |
daemon running |
dial <enode> |
Java | daemon running |
get-headers, get-block |
Java | at least one peer in READY state (check with peers) |
get-account, get-storage |
both | at least one snap-serving peer (snapServingPeers > 0 in status) |
get-account, get-storage, get-block (full beacon verification — verifyMethod populated, beaconChainVerified=true) |
— | beacon-status returns "state":"SYNCED" |
resolve-ens, reverse-ens, resolve-ens-* |
both (mainnet and Sepolia: ENS; Gnosis: Genome .gno names) |
at least one snap-serving peer |
build-logindex, logindex-backfill on|off, import-logindex <file…>, export-logindex <file> |
Rust (the Java engine answers an error) | daemon running (see Log index) |
logindex-status |
both — on the Java engine a stable {"enabled":false,"logCount":0,"entries":[]} |
daemon running |
get-transactions <address> |
Java, mainnet only (debug) | at least one peer in READY state |
purge-cache |
— | runs in the client with the daemon stopped: deletes the first network's peer caches and sync snapshot |
Account and storage queries return data with a Merkle proof against
the peer's stateRoot even before the beacon light client reaches
SYNCED. Until its first finalized root lands (SYNCING) the
response reports beaconChainVerified=false with
failReason: "beaconNotSynced"; after that (CATCHING_UP) it can
verify, but against a finalized root that may not be current. Wait
for SYNCED if you need the full beacon-anchored trust chain.
How long SYNCED takes depends on what's cached: a warm restart
(persisted sync snapshot + state-root window + known light-client
peers) reaches SYNCED in roughly 10 seconds; a cold start has to
bootstrap from the embedded checkpoint and catch the sync committee
up period by period (visible as CATCHING_UP with
currentPeriod/targetPeriod progressing), which takes minutes
when the checkpoint is old. Watch progress with:
./beacon-status.sh
# or
watch -n 2 ./beacon-status.sh./gradlew :app:run -Pargs=statusReturns daemon operational metrics.
| Field | Type | Description |
|---|---|---|
state |
string | Always "RUNNING" for an active daemon |
uptimeSeconds |
long | Daemon uptime in seconds |
discoveredPeers |
int | Total peers in the Kademlia DHT |
connectedPeers |
int | Total active TCP (RLPx) connections |
readyPeers |
long | Peers that completed the eth handshake |
snapPeers |
long | Ready peers that also negotiated snap (snap/1 or snap/2) |
snapServingPeers |
int | Snap peers in the serving pool right now — gate reads on this, not on snapPeers (#465). Rust engine: peers whose announced or served head is at or near the anchored head; Java engine: active snap sessions |
snap2ServingPeers |
int | The part of snapServingPeers whose connection runs snap/2 (EIP-8189). Informational — reads are the same on snap/1 and snap/2, so never gate on it. The Status screen shows it in parentheses after the serving count (serving 8 (3)), and omits them at 0 |
backedOffPeers |
long | Peers in temporary backoff (connect failure, busy, incompatible; the Rust engine also lagging). Rust engine: entries still blocking a dial at the pool's current shortfall — below target, a window shrinks in proportion to it, never under geth's 30 s inbound throttle (#539) |
blacklistedPeers |
long | Peers permanently blacklisted (incompatible network) |
./gradlew :app:run -Pargs=peersReturns discovered peers (from the Kademlia table) and connected peers with their state, snap support, and client ID. Java engine only — on the Rust engine the lists come back empty and the peer counts (snapPeers, snapServingPeers, …) in status are the surface.
./gradlew :app:run -Pargs=beacon-statusReturns beacon chain light client sync state.
| Field | Type | Description |
|---|---|---|
state |
string | "SYNCING" (no trust anchor yet), "CATCHING_UP" (anchor present, but the state-root window is still sparse or the held sync committee lags wall clock), "SYNCED" (verification-ready; can regress to CATCHING_UP if the node falls behind), or "STALE_ANCHOR" (syncing refused: the best available anchor is older than the weak-subjectivity bound — see §Weak-subjectivity age bound; the response carries anchorPeriod, anchorAgePeriods, wsBoundPeriods, and a warning) |
currentPeriod |
long | Sync-committee period the store currently holds (0 before bootstrap) |
targetPeriod |
long | Wall-clock sync-committee period being caught up to |
uptimeSeconds |
long | Daemon uptime |
discoveredPeers |
int | Live discv5 nodes |
connectedPeers |
int | Connected libp2p beacon peers |
lightClientPeers |
long | Connected peers advertising the light-client protocols |
finalizedSlot |
long | Latest finalized beacon slot (0 while SYNCING) |
optimisticSlot |
long | Latest optimistic (attested but not finalized) slot — the attested block's own slot, not the later signature slot of the sync aggregate (0 while SYNCING) |
finalizedPeriod |
long | finalizedSlot / 8192 (absent while SYNCING) |
syncCommitteePeriod |
long | Same as currentPeriod — the committee period the store verifies against, not finalizedSlot / 8192 (absent while SYNCING) |
wallClockPeriod |
long | Same as targetPeriod (absent while SYNCING) |
executionStateRoot |
string/null | Verified execution state root (null while SYNCING) |
executionBlockNumber |
long | Finalized execution block number (absent while SYNCING) |
knownStateRoots |
int | State roots in the rolling window cache |
fillThreshold |
int | Window size required for SYNCED (absent while SYNCING) |
peers |
array | Connected beacon peers (see below) |
Peer fields (in the peers array):
| Field | Type | Description |
|---|---|---|
peerId |
string | Truncated libp2p peer ID |
remoteAddress |
string | Peer's network address |
clientId |
string | Client identification string (if available) |
lightClient |
boolean | Whether peer supports the light client protocol |
protocols |
int | Number of advertised protocols |
./gradlew :app:run -Pargs=accept-stale-anchorOne-shot consent to sync forward from a trust anchor older than the weak-subjectivity
bound (see README §Weak-subjectivity age bound). Applies only while beacon-status
reports "state":"STALE_ANCHOR" — sent at any other time it answers
"applied":false without arming anything. Consent lasts for the current run only and
is never persisted; a restart with a still-stale anchor parks again. Prefer refreshing
the checkpoint (./gradlew refreshCheckpoint) over accepting.
# Get 3 headers starting at block 21000000
./gradlew :app:run -Pargs="get-headers 21000000 3"./gradlew :app:run -Pargs="get-block 21000000"Returns block header and body data with beacon chain verification.
Block fields:
| Field | Type | Description |
|---|---|---|
number |
long | Block number |
hash |
string | Block hash (keccak256 of RLP-encoded header) |
parentHash |
string | Parent block hash |
stateRoot |
string | State trie root after executing this block |
transactionsRoot |
string | Transactions trie root |
receiptsRoot |
string | Receipts trie root |
timestamp |
long | Block timestamp (Unix seconds) |
gasUsed |
long | Total gas used by all transactions |
gasLimit |
long | Block gas limit |
baseFeePerGas |
string | EIP-1559 base fee (post-London only) |
transactionCount |
int | Number of transactions in the block |
uncleCount |
int | Number of uncle blocks (always 0 post-Merge) |
withdrawalCount |
int | Number of validator withdrawals (post-Shanghai) |
Verification fields (in the verification object):
| Field | Type | Description |
|---|---|---|
beaconSynced |
boolean | Whether the beacon light client has synced |
beaconChainVerified |
boolean | Whether the block is verified against the beacon chain |
verifyMethod |
string | "headerChain" (only when beaconChainVerified=true; a block is proven by its hash, never by a matching state root, which would vouch for the root but not the rest of the header) |
matchedBeaconSlot |
long | Beacon slot trust anchor (only when beaconChainVerified=true) |
blsVerified |
boolean | Whether the trust anchor has BLS verification (only when beaconChainVerified=true) |
failReason |
string | Why verification failed (only when beaconChainVerified=false, see below) |
failReason values:
| Value | Description |
|---|---|
preMergeBlock |
Block is before The Merge (block 15,537,394). Pre-merge blocks cannot be verified via the beacon chain. |
blockAheadOfAnchor |
The block is above the light client's optimistic head: nothing attested is above it yet to walk down from. |
headerChainGapTooLarge |
The block is 8,192 or more blocks below the attested block its walk ends at (the finalized block, for a block at or below finality). |
beaconNotSynced |
The beacon light client has not synced yet. |
beaconBlockHashUnavailable |
The beacon state does not have a finalized block hash to anchor against. |
headerChainInvalid |
A header chain was fetched but failed validation (hash mismatch or discontinuity). |
headerChainError |
An error occurred while fetching or verifying the header chain. |
Block verification model:
Block verification works differently from account/storage verification. Account and storage queries have two layers of protection: a Merkle-Patricia proof (binding data to a state root) and beacon chain linkage (proving the state root is canonical). Block verification has only the beacon chain linkage layer -- there is no Merkle proof for block inclusion.
The verification anchors to the beacon chain's ExecutionPayloadHeader.block_hash, which is verified by sync committee BLS signatures. The finalized block header is fetched from a peer, and its locally-computed keccak256(RLP) is compared against this beacon-attested hash. From that anchor, a parent-hash chain is walked to or from the requested block. Each link is pinned by the previous header's keccak256 hash, making forgery require a preimage attack.
./gradlew :app:run -Pargs="get-account 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"Returns account data with a Merkle-Patricia proof and cryptographic verification.
Account fields:
| Field | Type | Description |
|---|---|---|
exists |
boolean | Whether the account was found in the state trie |
address |
string | The queried address (0x-prefixed) |
accountHash |
string | keccak256 of the address |
nonce |
long | Account transaction count (only if exists=true) |
balance |
string | Account balance in wei (only if exists=true) |
storageRoot |
string | Storage trie root hash (only if exists=true) |
codeHash |
string | Contract code hash (only if exists=true) |
proof |
array | Merkle-Patricia trie proof nodes (RLP-encoded, hex) |
Verification fields (in the verification object):
| Field | Type | Values | Description |
|---|---|---|---|
peerProofValid |
boolean | true / false |
Whether the Merkle proof is cryptographically valid against the peer's state root. Proves the data is authentic relative to the peer's claimed state, but does not prove the state root itself is canonical. |
peerStateRoot |
string | 0x-prefixed hex | The state root the peer provided, against which the proof was verified. |
beaconSynced |
boolean | true / false |
Whether the beacon chain light client has synced (has a finalized state root). |
beaconChainVerified |
boolean | true / false |
Whether the peer's state root is verified against the beacon chain. This is the critical trust anchor: true means the data is cryptographically backed by sync committee signatures. |
verifyMethod |
string | "stateRootMatch" / "headerChain" |
How the beacon chain verification was achieved (only present when beaconChainVerified=true). See below. |
matchedBeaconSlot |
long | slot number | The beacon slot used as the trust anchor (only present when beaconChainVerified=true). Beacon slots increment every 12 seconds. |
blsVerified |
boolean | true / false |
Whether the trust anchor slot was validated via BLS sync committee signatures (only present when beaconChainVerified=true). true means at least 2/3 of the sync committee signed off on the data. |
verifyMethod values:
stateRootMatch-- The peer's state root exactly matches a state root from a recent beacon block header stored in the rolling window cache. This is the most direct verification path.headerChain-- The peer's block is between the finalized block and the light client's optimistic head, so the consecutive block headers from the peer's block UP to the optimistic head were fetched and verified. Verification checks: (1) the last header's block hash matches the optimistic head's BLS-attested hash, (2) each header's hash matches the next header's parent hash, (3) the first header's state root matches the peer's root. The walk must end at the attested block: a parent hash pins a header's parent, never its children, so trust flows only down from an attested block. (Releases through v0.1.14 ran the walk upward from the finalized block, which pinned nothing above it: a peer could make up a child of the finalized block with any state root.)
failReason values (when beaconChainVerified=false; applies to both get-account and get-storage):
| Value | Description |
|---|---|
noPeerStateRoot |
The peer did not provide a state root to verify against. |
peerProofInvalid |
The Merkle proof failed against the peer's claimed state root. |
beaconNotSynced |
The beacon light client has not synced yet. |
noPeerBlockNumber |
The peer's block number is unknown, so a header chain can't be anchored. |
beaconBlockUnavailable |
The beacon state has no finalized execution block to anchor against. |
peerBlockBehindFinalized |
The peer's block is at or below the beacon-finalized block — too old to answer with. |
peerBlockAheadOfAnchor |
The peer's block is above the light client's optimistic head: nothing attested is above it yet to walk down from. |
headerChainGapTooLarge |
The peer's block is 8,192 or more blocks below the optimistic head. |
headerChainInvalid |
A header chain was fetched but failed validation (hash mismatch or discontinuity). |
headerChainError |
An error occurred while fetching or verifying the header chain. |
# Direct slot access
./gradlew :app:run -Pargs="get-storage 0x<contract> <slot>"
# ERC-20 balance lookup (mapping slot with holder address)
./gradlew :app:run -Pargs="get-storage 0x<token> <slot> 0x<holder>"Returns storage slot data for a contract with Merkle-Patricia proof verification. For ERC-20 tokens, pass the mapping slot number and holder address to compute keccak256(abi.encode(holder, slot)).
Storage fields:
| Field | Type | Description |
|---|---|---|
address |
string | Contract address queried |
slot |
long | Slot number |
holder |
string | Holder address (only for ERC-20 mapping lookups) |
storageKey |
string | Computed storage key (0x-prefixed hex) |
storageKeyHash |
string | keccak256 of the storage key |
exists |
boolean | Whether the slot has a value |
value |
string | Storage value in hex (only if exists=true) |
valueDecimal |
string | Storage value as decimal (only if exists=true) |
slotsReturned |
int | Number of slots returned by peer (only if exists=false) |
storageRoot |
string | Account's storage trie root |
proof |
array | Merkle-Patricia proof nodes (RLP-encoded, hex) |
Verification fields (in the verification object):
| Field | Type | Description |
|---|---|---|
storageProofValid |
boolean | Whether the storage proof is valid against the account's storage root |
beaconSynced |
boolean | Whether the beacon light client has synced |
beaconChainVerified |
boolean | Whether the state is verified against the beacon chain (same logic as get-account) |
verifyMethod |
string | "stateRootMatch" or "headerChain" (same as get-account, only present when beaconChainVerified=true) |
matchedBeaconSlot |
long | Beacon slot trust anchor (only present when beaconChainVerified=true) |
blsVerified |
boolean | Whether the trust anchor has BLS verification (only present when beaconChainVerified=true) |
./gradlew :app:run -Pargs=read-statsThe read-fetch shadow cache: counters over every verified account /
storage / bytecode fetch this daemon made (the wallet's JSON-RPC reads, the
eth_call oracle, and the operator queries above), classifying each repeat by
which cache keying would have made it unnecessary and how much wall-clock it
cost — sameStateRoot (a per-block cache), sameStorageRoot (the sound
cross-block scheme, keyed by the account's storage root), sameValue /
unchanged (the ceiling no proof-based cache reaches), and byAge buckets
(how often a value up to 12 s / 60 s / 5 min old would have been correct).
Nothing is served from it; it exists to decide the next caching step on
measured traffic. Field-by-field guide: docs/read-stats.md.
./gradlew :app:run -Pargs="resolve-ens vitalik.eth"Resolves an ENS name to an Ethereum address by running the ENS contracts in a local EVM with state served from SNAP proofs. Supports vanilla *.eth names, ENSIP-10 wildcard resolution, and ERC-3668 off-chain (CCIP-Read) lookups.
Response fields:
| Field | Type | Description |
|---|---|---|
ok |
boolean | true if the resolution attempt completed without protocol errors |
resolved |
boolean | true if a non-zero address was returned, false if the name has no record set |
name |
string | The queried name |
address |
string | Resolved address (0x-prefixed, only if resolved=true) |
beaconVerified |
boolean | true when resolution ran against the beacon-verified finalized state (the default), so the name→address mapping is anchored to a beacon-attested root |
blockNumber |
long | Execution block at which the resolution was performed |
How it works:
- By default (AUTO) the daemon resolves against the light client's beacon-verified finalized execution state root first — no peer-head probe. Only if that yields no address (the record didn't exist at the finalized block) or can't be served does it fall back to a peer's head. (See Resolution root below.) A snap-capable peer serves the state; the proofs descend from the chosen
stateRoot. - The local EVM (revm on the Rust engine, Hyperledger Besu's standalone EVM module on the Java engine) discovers the name's resolver through the ENS Registry — an ENSIP-10 walk: the exact node, then each parent — and calls it directly:
resolve(bytes name, bytes data)on an ENSIP-10 (wildcard / off-chain) resolver, or the record method itself on a legacy resolver. No Universal Resolver and no shared CCIP batch gateway sit in the path. Every account field, storage slot, and contract bytecode the EVM reads is fetched on demand via snap and verified by Merkle-Patricia proof against thatstateRoot. - If the call reverts with
OffchainLookup(ERC-3668), the daemon fetches the gateway response over HTTPS from the resolver's own gateway (Coinbase's forcb.id, for example — no third-party relay) and re-enters the EVM with the resolver's callback. The callback validates the gateway's response on-chain — typically by checking a signer's signature against a list of trusted signers embedded in the resolver — so a malicious gateway cannot inject a wrong answer. - The resolver's return value is decoded as the resolved address.
Resolution root (trust vs freshness):
The state ENS executes against is configurable via io.myotis.ens.EnsResolutionRoot:
AUTO(default) — resolve against the beacon-verified finalized state first; if that yields no address (the record didn't exist at the finalized block — e.g. a name registered or anaddr()first set in the last ~12 min) or errors (a snap peer doesn't retain that block's state), fall back to the peer head. You get a beacon-verified mapping (beaconVerified=true) whenever one exists, and brand-new names still resolve (beaconVerified=false, peer-claimed). A record that changed in the last ~12 min resolves to its verified-but-stale finalized value (staleness, not a failure) — AUTO only falls back when finalized returns no address.FINALIZED— finalized state only; no fallback. AlwaysbeaconVerified=true, but brand-new names return "does not resolve."PEER_HEAD— resolve against a snap peer's latest head state. Freshest possible data, but the head root is the peer's claim, not beacon-attested, so the mapping is peer-claimed (beaconVerified=false). Choose this when you need the very latest ENS state and accept the weaker trust. (On Android, set viaNodeService.setEnsResolutionRoot(...).)
On the Rust engine the same choice is the root parameter of ens_record_json — the call the JVM hosts issue every ENS query through: auto (default), finalized, or optimistic — the beacon-anchored optimistic head, which stands in for PEER_HEAD. The bare forward lookup resolve_ens_json (Node addon resolveEnsJson) takes no root and resolves against the optimistic head in one attempt — no finalized-first preference; a host that wants auto or finalized goes through ens_record_json. The Rust engine has no peer-claimed mode: every ENS answer it gives is anchored to a beacon-attested root, finalized or optimistic.
Trust model:
- State: every read backed by a Merkle proof against the
stateRoot— beacon-verified inFINALIZEDmode. - Bytecode: verified by
keccak256(code) == codeHashfrom the proof-verified account. - CCIP-Read gateways: trusted only for availability — the resolver's callback validates the response cryptographically. A lying gateway causes the call to revert, surfacing as a clean failure.
The same trust model and resolution root apply to every other resolve-ens-* command.
Networks: mainnet and Sepolia have the canonical ENS contract addresses pinned. Gnosis has no ENS, but it has Genome's .gno names — a SPACE ID deployment with the ENS registry and resolver interfaces whose names hash under a per-TLD root node — so on Gnosis the same commands resolve .gno names (and only those) against verified Gnosis state; reverse-ens is refused there (Genome's reverse records are not implemented). The Genome pins can be checked against a running Gnosis node with scripts/check_genome_pins.py. (Holesky's addresses are still in EnsResolver, dead code since the network was retired in October 2025.)
Validated names (mainnet):
# Vanilla ENS (Public Resolver)
./gradlew :app:run -Pargs="resolve-ens vitalik.eth"
# → 0xd8da6bf26964af9d7eed9e03e53415d37aa96045
# CCIP-Read demo (EIP-3668 reference gateway)
./gradlew :app:run -Pargs="resolve-ens 1.offchainexample.eth"
# → 0x41563129cdbbd0c5d3e1c86cf9563926b243834d
# Coinbase ID (CCIP-Read via Coinbase's gateway)
./gradlew :app:run -Pargs="resolve-ens jesse.cb.id"
# → 0x849151d7d0bf1f34b70d5cad5149d28cc2308bf1./gradlew :app:run -Pargs="resolve-ens-text vitalik.eth avatar"
./gradlew :app:run -Pargs="resolve-ens-text vitalik.eth com.twitter"Reads a text record (ENSIP-5) for a name. Common keys: avatar, url, description, email, notice, keywords, com.twitter, com.github, org.telegram.
Response fields:
| Field | Type | Description |
|---|---|---|
name |
string | The queried name |
key |
string | The text-record key |
resolved |
boolean | true if a non-empty value was returned |
value |
string | The record value (only if resolved=true) |
blockNumber |
long | Execution block at which the resolution was performed |
./gradlew :app:run -Pargs="resolve-ens-contenthash vitalik.eth"Reads the contenthash (ENSIP-7) — a multicodec-encoded pointer to IPFS, Swarm, IPNS, or Arweave content. Used to attach a decentralized website to an ENS name.
Response fields: name, resolved, contenthash (0x-prefixed multicodec bytes; decoder is the caller's responsibility), blockNumber.
./gradlew :app:run -Pargs="resolve-ens-ownership myotis.eth"Who holds the name and until when: the registry's owner (the manager, who sets records and subnames) and resolver, and for a .eth second-level name the BaseRegistrar's token owner (the registrant), nameExpires and GRACE_PERIOD — each seen through the chain's NameWrapper where it is the holder (wrapped). No resolver is involved; the reads are plain registry and registrar calls, verified like every other ENS read.
Where ENSv2 is live (Sepolia today; #671), the read is answered from the v1 registry only for a name ENSv2 still hands to v1 (one not migrated yet). Every other name is refused with an error, whether ENSv2 registered it or not, since the v1 registry would show an ENSv2 name as unowned and a superseded v1 entry as owned. DNS names count among them. Mainnet is unaffected until ENSv2 activates there.
Response fields: name, blockNumber, verified, wrapped, then where present: registrant, manager, resolver, expiresAt (unix seconds) with gracePeriodSeconds. A subname has no registrar part; an expired name has an expiresAt in the past and no registrant (the registrar's ownerOf reverts from the expiry on, grace period included — only the former registrant may renew inside it). Whether a .eth name is taken is therefore a question of time: expiresAt + gracePeriodSeconds against the clock, since the registry keeps the old owner and the registrar the old expiry long after the name became free.
# Bitcoin (SLIP-44 coinType 0)
./gradlew :app:run -Pargs="resolve-ens-addr-coin some-name.eth 0"
# Litecoin (SLIP-44 coinType 2)
./gradlew :app:run -Pargs="resolve-ens-addr-coin some-name.eth 2"
# Solana (SLIP-44 coinType 501)
./gradlew :app:run -Pargs="resolve-ens-addr-coin some-name.eth 501"Reads an addr(node, coinType) record (ENSIP-9 / SLIP-44). The default addr command returns the ETH-mainnet address (coinType=60); this two-arg form returns the chain-specific address bytes for any coinType the resolver has set.
Response fields: name, coinType, resolved, address (0x-prefixed raw chain-specific bytes — for Bitcoin this is the script payload, not a base58 address; the caller is responsible for decoding for that chain), blockNumber.
./gradlew :app:run -Pargs="resolve-ens-pubkey some-name.eth"Reads the secp256k1 public-key record (EIP-619). Niche — used for end-to-end-encrypted DM flows.
Response fields: name, resolved, pubkeyX, pubkeyY (each 0x-prefixed 32-byte hex), blockNumber. Returns resolved:false when both coordinates are zero.
# Default contentTypes mask = 0xF (any encoding)
./gradlew :app:run -Pargs="resolve-ens-abi some-name.eth"
# Explicit mask: 1 = Solidity ABI JSON only
./gradlew :app:run -Pargs="resolve-ens-abi some-name.eth 1"Reads an ABI record (EIP-205) — ABI metadata for a contract owned by the name. contentTypes is a bitmask: 1 = Solidity ABI JSON, 2 = zlib-compressed JSON, 4 = CBOR, 8 = URI. Default is 15 (all).
Response fields: name, contentTypes, resolved, contentType (which encoding the resolver chose), data (0x-prefixed bytes in that encoding), blockNumber.
Reads a DNS record (ENSIP-8) stored under the name. The DNS record returned can include DNSSEC RRSIG bytes; the caller is responsible for parsing and (if desired) DNSSEC-verifying the bytes.
./gradlew :app:run -Pargs="resolve-ens-dns some-name.eth www.example.com 1"Positional args are <name.eth> <dnsName> <resource>. resource is a DNS resource type (1 = A, 28 = AAAA, 16 = TXT, 33 = SRV, …).
Response fields: name, dnsName, resource, resolved, data (raw RDATA bytes), blockNumber.
# 0x5b5e139f is the EIP-165 selector for ERC-721 Metadata
./gradlew :app:run -Pargs="resolve-ens-interface some-name.eth 0x5b5e139f"Reads interfaceImplementer(node, interfaceId) (EIP-1820 over ENS) — the address of a contract implementing the given EIP-165 interface for this name's owner. Positional args are <name.eth> <0xInterfaceId>.
Response fields: name, interfaceId, resolved, implementer (0x-prefixed 20-byte address), blockNumber.
./gradlew :app:run -Pargs="get-transactions 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"Returns all transactions for an address (mainnet only, Java engine only — the scan wraps the Java engine's raw connector and is a debug feature, not a verified read) by looking them up in the TrueBlocks Unchained Index. Results are streamed as JSON-Lines, newest first, followed by a summary object. The same scan powers the desktop and Android apps' Query tab ("Find transactions" under an address lookup): hits appear as block-number placeholders and upgrade in place to parsed rows, with a progress bar and a Stop button. (On Android the bloom/index cache lives under filesDir/trueblocks and can grow to multi-GB after a deep scan.)
How it works:
- Resolves the latest manifest CID from the UnchainedIndex_V2 contract on mainnet (
manifestHashMap(publisher, "mainnet")at0x0c316B70…183d) using myotis' own verified eth_call — no external RPC. The publisher ispublisher.unchainedindex.eth(chifra's preferred publisher, resolved via myotis' verified ENS; the manifest map is permissionless, so only the ENS-designated publisher's slot is trusted — even trueblocks.eth's own slot holds a junk placeholder). The result is cached for 24h; when the node isn't synced the cached (or, as a last resort, a hardcoded known-good) CID is used and the output says so viacidSource. - For each chunk in the manifest (scanned from newest to oldest blocks):
- Downloads the Bloom filter (disk-cached under
trueblocks/, content-addressed, immutable) and checks if the address appears - On a Bloom hit, downloads the index chunk (also cached) and extracts appearance records (block number + transaction index)
- Downloads the Bloom filter (disk-cached under
- For each appearance, fetches the block header and body from devp2p peers and extracts the raw transaction
- Decodes the transaction (legacy, EIP-2930, EIP-1559, EIP-4844, EIP-7702; sender recovered from the signature) and classifies it: plain ETH transfer, ERC-20
transfer/transferFromof a well-known token (USDC, USDT, DAI, WETH, WBTC, …), generic contract call, or contract creation
Stream shape (succinct — one line per transaction, no raw tx hex):
{"ok":true,"manifestCid":"Qm…","cidSource":"contract","chunks":8123,"latestIndexedBlock":22841000,"headBlock":22843511}
{"ok":true,"blockNumber":22812345,"transactionIndex":41,"hash":"0x…","from":"0x…","to":"0xa0b86991…","kind":"erc20","token":"USDC","amount":"1250.50","recipient":"0x…","verified":false}
{"ok":true,"blockNumber":22711000,"transactionIndex":3,"hash":"0x…","from":"0x…","to":"0x…","kind":"eth","eth":"1.25","wei":"1250000000000000000","verified":false}
{"ok":true,"blockNumber":22600000,"transactionIndex":97,"hash":"0x…","from":"0x…","to":"0x…","kind":"call","calldataBytes":132,"selector":"0x38ed1739","verified":false}
{"ok":true,"progress":{"chunksScanned":250,"totalChunks":8123,"range":"022300000-022310000","hits":3,"bytesDownloaded":31457280}}
{"ok":true,"done":true,"totalTransactions":3}
kind is one of eth (plain transfer; eth = amount in ETH, wei = exact), erc20 (well-known-token transfer; decimals-adjusted amount + decoded recipient), call (generic call; calldataBytes + 4-byte selector, plus eth when value is attached), or create (contract creation). Failed resolutions stream as {"ok":false,"blockNumber":…,"error":"…"} and the scan continues. A progress heartbeat line appears every 250 chunks.
Limitations:
- Blocks are not verified. Individual transactions are not yet verified against the block's
transactionsRoot. Theverifiedfield is alwaysfalse. This means a malicious peer could serve tampered transaction data. Verification against the transactions trie is planned but not implemented. - Index freshness depends on the publisher — and upstream publishing appears stalled. The manifest CID tracks the on-chain publication, so the index is only as fresh as the designated publisher's latest publish. As of mid-2026 that is ~block 23.0M (roughly a year behind the head):
publisher.unchainedindex.ethhas not published a newer mainnet manifest, and the only newer-looking on-chain entry comes from an undesignated wallet whose content isn't retrievable from IPFS at all. Transactions abovelatestIndexedBlockwill not be found. Both surfaces now make this loud: theStartedIPC line carriesindexLagBlocks/indexAgeDaysplusstale:true+ awarningbeyond ~14 days of lag, and the desktop Query tab shows a warning banner with the approximate age and cutoff block. The planned remedy is a self-published index (running the TrueBlocks scraper and pinning/serving the chunks ourselves). - First full-history scan is heavy. Every chunk's bloom filter is downloaded once (multi-GB across the whole chain history, kept forever in the
trueblocks/cache); subsequent scans read blooms from disk. Newest chunks stream first, so recent history appears long before the scan completes — and the scan can be stopped at any point. - Completeness is not guaranteed. IPFS content-addressing guarantees the index data has not been tampered with, but it does not guarantee all appearances for an address are present. If the TrueBlocks index has missing entries (due to indexing gaps or incomplete coverage), transactions will be silently missed. A future mitigation is balance reconciliation -- computing the expected balance from fetched transactions and comparing it against the on-chain balance via snap proofs.
- Signature and access list data not returned. The succinct JSON omits signature fields (v, r, s), access lists, blob hashes, and the raw transaction bytes; use
eth_getTransactionByHash(verified JSON-RPC) with the returnedhashfor full detail on recent transactions.
./gradlew :app:run -Pargs="dial enode://..."Convenience scripts that wrap the Gradle commands and format the output with jq:
Lists connected peers in READY state, sorted by snap support and client ID:
./peers.sh
# Output:
# 1.2.3.4:30303 snap=true Geth/v1.15.0/linux-amd64/go1.23.4
# 5.6.7.8:30303 snap=true Nethermind/v1.30.0/...Prints daemon status as JSON:
./status.sh
# {"ok":true,"state":"RUNNING","uptimeSeconds":120,"discoveredPeers":214,...}Prints beacon light client sync status:
./beacon-status.shThe same two commands sent to the Gnosis daemon (-Pnetwork=gnosis).
The daemon includes a consensus-layer light client that tracks finalized state roots from the beacon chain. This enables trustless verification of account and storage proofs against the canonical chain state.
The only trust anchors are sync committee BLS signatures (chained from the embedded checkpoint) and, by design, the embedded historical hash accumulators — the accumulators are not built yet (see Implementation Status §2), so today every verified answer rests on the sync-committee chain alone. All data from devp2p and libp2p peers is cryptographically verified -- no trusted third-party RPCs or HTTP APIs are used in production (one bounded, documented exception: a seeded log history served on its publisher's word, docs/seeded-log-histories.md).
The bootstrap trust anchor is a 32-byte mainnet block root hardcoded in NetworkConfig.MAINNET.checkpointRoot. Every LightClientBootstrap response is rejected unless hash_tree_root(response.header) equals this committed value, so the pin is cryptographic: no peer (libp2p or HTTP checkpoint endpoint) can substitute a different anchor, even an internally-consistent one, without finding a SHA-256 preimage.
Ethereum's weak-subjectivity period — how old a trust anchor may be before validators who have since exited could sign a competing "finalized" history (a long-range attack) that a light client cannot distinguish from the real one — is roughly two weeks on mainnet at current stake levels (the consensus-spec formula with SAFETY_DECAY=10 plateaus at 3532 epochs ≈ 15.7 days; Electra's exit-churn cap only lengthens it). It is not the ~27-hour sync-committee period, though the two are easy to conflate because MIN_VALIDATOR_WITHDRAWABILITY_DELAY happens to be 256 epochs too. What a fresh anchor buys is attribution, not a slashing penalty: sync-committee signatures are not a slashable offense on any Ethereum chain (bonded or not — the beacon chain slashes only proposer and attester equivocation), so the real protection is that a recent anchor's committee is still held by identifiable, staked validators who would be publicly implicated by a forged signature, and the exit-churn limit caps how fast those keys turn into freely-acquirable, consequence-free ones. So the committed root needs to be refreshed periodically or binaries eventually age past the safety envelope — and refreshing every sync-committee period, as the tasks below encourage, keeps the anchor ~13× fresher than the strict bound requires. The repo ships with a Gradle task that fetches a current finalized root, cross-validates it against multiple independent providers, and rewrites the @checkpoint:mainnet region of NetworkConfig.java:
# Preview the diff without writing
./gradlew refreshCheckpoint -Pnetwork=mainnet -Pdry
# Fetch + write (commit the resulting diff — BOTH engines move together)
./gradlew refreshCheckpoint -Pnetwork=mainnet
# All three networks in one run
./gradlew refreshCheckpointThe task queries /eth/v2/beacon/blocks/finalized on four independent mainnet endpoints (ethereum-beacon-api.publicnode.com, beaconstate.info, sync-mainnet.beaconcha.in, mainnet-checkpoint-sync.attestant.io) to discover the finalized slot, normalizes to the oldest observed, then asks every responder /eth/v1/beacon/blocks/{slot}/root for the canonical block root at that slot and requires byte-for-byte agreement before writing. Agreement is counted per operator (registrable domain), not per URL, so two hostnames of one provider are one voice. Any disagreement aborts without modifying the source.
The same task serves every network — -Pnetwork=sepolia, -Pnetwork=gnosis and -Pnetwork=chiado refresh the @checkpoint:<net> regions, and a bare ./gradlew refreshCheckpoint does all four. It refuses to write a root fewer than two operators agree on; -PallowSingleSource is the explicit opt-out, which Gnosis sometimes needs and mainnet and Sepolia do not. Chiado has exactly two operators (publicnode and the Gnosis team's chiadochain.net), so one of them being down fails its refresh — and since it runs last in a bare ./gradlew refreshCheckpoint, a red run there has already written mainnet, Sepolia and Gnosis; retry -Pnetwork=chiado, or add -PallowSingleSource knowingly.
Each run rewrites both engines from one fetch — NetworkConfig.java and the Rust ChainConfig in rust/myotis-net/src/sync.rs — because a hand-mirrored anchor is a split anchor waiting to happen. The java_and_rust_checkpoints_agree test fails if they ever diverge.
Both engines enforce the window above at every sync start — cold, and on a warm resume from idle-pause (a pause longer than the bound ages the held committee exactly like a cold snapshot) — re-face it at every bootstrap attempt (the poll loop's retry and the fallback after a failed snapshot resume judge the embedded checkpoint, which can be older than the anchor the start-time gate approved), and re-check it continuously while running: a node that stays awake but peer-starved (or eclipsed) past the bound parks the same way before catching up, so restart-vs-stay-running never decides whether the gate applies. Before resuming or bootstrapping, the client judges the age of the best anchor it has — the embedded checkpoint or the persisted sync snapshot, whichever is newer (when the checkpoint is the newer of the two, the snapshot is discarded and the sync restarts from the checkpoint's period, as it always has). If that anchor is older than the network's bound — mainnet 13 periods (~14.7 days), sepolia 13, gnosis 3, chiado 1 — the client refuses to sync and parks in STALE_ANCHOR, failing closed: no bootstrap, no verification, verified queries keep erroring. Nothing is deleted; the client is waiting for a decision:
- Update the binary / refresh the checkpoint (
./gradlew refreshCheckpoint) — the recommended fix; the wallet cannot fetch a fresh anchor itself (devp2p/libp2p only, no trusted RPCs). - Raise the bound — Settings → "Weak-subjectivity bound" in the apps (0 = network default; applied live, a parked chain re-evaluates within a second), or
-Dmyotis.beacon.wsBoundPeriods=Non the daemon. Raising it weakens the long-range-attack guarantee, knowingly. - Accept the risk for this run — the apps show a dialog explaining the age and the risk ("Sync anyway"); the daemon takes
./gradlew :app:run -Pargs=accept-stale-anchor(only applies while actually parked), or-Dmyotis.beacon.acceptStaleAnchor=trueat start for deliberate pre-consent (e.g. re-syncing an archived data dir). Consent is never persisted — a restart with a still-stale anchor parks, and asks, again. - Supply a fresher checkpoint yourself (embedding hosts, Node addon / plain C ABI, ABI ≥ 26) —
createWithCheckpoint(network, freshDir, blockRoot, headerSlot)bootstraps a fresh directory from a beacon block root the host obtained and authenticated through its own channels. This does not add a trust anchor to Myotis: the engine treats the root exactly like the embedded checkpoint — bootstrap pinned to it, every update BLS-verified against the committee chain that follows, snapshot probation, and the same weak-subjectivity gate on the supplied slot's age — and it does not vouch for the root's honesty; the host does, and must say so to its users. The directory is bound to that anchor (sync-anchor[-net].json) and resumes only with the same root and slot; the Java engine and plaincreate()refuse it rather than silently swapping anchors. See rust/myotis-node/README.md.
Why this exists: the forward walk is only as trustworthy as the anchor it starts from. Past the weak-subjectivity period, an attacker who acquired keys of since-exited committee members could serve a validly-signed forged continuation, and BLS verification alone cannot tell it from the honest chain — so an over-age anchor must be an explicit, informed user decision, never a silent default. Per-network derivations are documented at NetworkConfig.wsBoundPeriods(); the Rust mirror lives in the ChainConfig constructors and is covered by tests on both sides. The default anchor is head, and that is the one to ship: the gate above refuses an anchor older than the bound, so a release anchored further back parks every fresh install on day one (gnosis's 3-period bound means its anchor stays fresh for only ~34 h after the refresh, so tag promptly — and any gnosis install more than ~2 days after the refresh still starts at the STALE_ANCHOR consent dialog; that is the expected gnosis first run, not a broken release). Use -Pperiod=<n> to anchor at a chosen sync-committee period instead, or -Pslot=<n> to pin an exact slot — for testing a specific retained state on the serving node, such as the oldest bootstrap it can still answer; both require naming the chain with -Pnetwork and refuse otherwise, and a pin below the serving node's archive floor is unreachable forever (the archive only grows forward). Historical slots may need a full node, named with -PextraEndpoint=<url> and checked against the pinned genesis_validators_root before it counts. To test from the oldest anchors roost's serving nodes can still bootstrap, without waiting on (or depending on) any endpoint, -PanchorFile=rust/testdata/anchors/oldest-servable.properties writes their recorded roots into both engines with no network access; that file carries the provenance and the caveat that every entry opens on the stale-anchor dialog.
- The beacon light client obtains a finalized execution state root (BLS-verified via sync committee signatures)
- When a snap query returns account/storage data with a Merkle proof, the proof is first verified against the peer's state root
- The peer's state root is then linked to the beacon-finalized state root via one of:
- Direct match -- the peer's state root matches a known beacon-attested root
- Header chain verification -- block headers are fetched in batches from the peer's block up to the light client's optimistic head, verifying: (a) the last header's block hash matches the optimistic head's attested hash, (b) consecutive parent hash chain integrity, (c) the first header's state root matches the peer's root
Block verification (get-block) currently has the following limitations:
-
Pre-merge blocks (before block 15,537,394) cannot be verified. The beacon chain only exists post-Merge, so there is no sync committee anchor for proof-of-work era blocks.
-
Post-merge blocks 8,192 or more blocks below the finalized block cannot be verified via header chain. This covers roughly 27 hours of blocks at 12-second slots.
-
Blocks above the light client's optimistic head (typically the newest block or two) cannot be verified yet: the walk needs an attested block above them.
-
Account and storage queries (
get-account,get-storage) share the 8,192-block header chain limit but are less affected in practice because they query the peer's current state (usually close to head). Roadmap: -
Pre-merge blocks: Implement the pre-merge historical hashes accumulator. This is a Merkle tree over all ~15.5M pre-merge block hashes, with the root embedded as a static trust anchor. Any pre-merge block hash can be verified with an inclusion proof against this accumulator.
-
Post-merge historical blocks: Implement the Bellatrix-era historical roots accumulator. The beacon chain stores
historical_roots(batches of 8,192 slots) andhistorical_summaries(post-Capella) that cover all post-merge execution payloads. By walking the beacon state's historical records, any post-merge block hash can be verified without an 8,192-block proximity constraint.
The light client syncs from the beacon chain P2P network (libp2p) -- fully decentralized, using BLS-verified bootstrap and finality updates.
Debug only (not for production): During development, the light client can also seed initial state from a local beacon node's HTTP API (e.g. Lighthouse on
http://localhost:5052). This is a convenience fallback for debugging and will not be part of a future release. Helper scripts:scripts/lighthouse.sh,scripts/lodestar.sh.
Myotis ships two engines behind one contract: the original Java engine and the Rust engine, which reimplements the whole verification stack natively and is the primary engine — the default selection, and ahead of the Java engine on features (the eth_getLogs log index and Tor verified-read routing run on the Rust engine only). Both are interchangeable per network at (re)start:
- The contract is
:myotis-api— zero-dependency Java-17 interfaces (MyotisEngine/ChainHandle, FFI-portable types only). Hosts (Android app, desktop app, daemon) consume only this API and never import engine internals. - The Java engine is the original implementation (
node-coreadapters over thenetworking/consensus/myotis-evmmodules). - The Rust engine is the
rust/Cargo workspace (myotis-core,myotis-wire,myotis-net,myotis-consensus,myotis-bls,myotis-evm,myotis-engine): discv4 + discv5 discovery, RLPx/eth/snap, the beacon light client with BLS via blst, a revm-based EVM with the same snap-proof state oracle, ENS incl. CCIP-Read, and multichain (mainnet, Sepolia, Gnosis, Chiado). It reaches the JVM through UniFFI-generated Kotlin bindings over JNA (the generated bindings are committed in:myotis-engine-rust, regenerated viauniffiGenerateKotlin); compound values cross as JSON, pinned by golden tests on both sides. The same engine's plain C ABI also serves the two non-JVM hosts: the iOS app (Kotlin/Native cinterop) and the Node.js addon (rust/myotis-node, napi-rs) — identical JSON shapes, pinned by the same golden tests. - Selection: the
:myotis-enginesselector (Engines.engine()) routes each network to an engine via themyotis.engineproperty —auto(default: the Rust engine where it can serve, Java fallback otherwise),java, orrust(hard — error when unavailable). On run tasks use-Pengine=javato opt out; in the apps it's the "Prefer Java engine" Settings toggle (applies on network restart). Android below API 33 (Android 13) is Rust-only: the Java engine's EVM (Besu'sUInt256) and its discv5 (Guava futures) useVarHandleAPIs that Android 10–12 keep hidden, so the app forcesrustthere with no Java fallback, and Settings explains this instead of offering the toggle. The Status screen shows which engine hosts each network — "Mainnet (r)" vs "(j)". - Parity is enforced by shared conformance vectors (BLS fixtures, a captured mainnet light-client corpus, the EL verification-ladder vectors) run against both implementations, plus a benchmark gate for the JNI path.
rustc/cargo are NOT required to build or run the JVM hosts (daemon + desktop).
That build is pure Java/Kotlin end to end: the UniFFI-generated Kotlin bindings are
committed source (no bindgen step for a JVM-host build), JNA comes from Maven Central
like any other dependency, and every cargo* Gradle task self-skips with a single
note when cargo is missing. On a cargo-less machine the JVM hosts' engine selector
simply reports the Rust engine as unavailable and the default auto selection
falls back to the Java engine — there is nothing to configure or disable. The
Android app is the exception: it builds the Rust engine from source by default
(cargo + cargo-ndk + NDK + the Android rustup targets; its preBuild runs
cargoNdkAndroid and regenerates the bindings). There is no committed .so, so
nothing can drift; a missing toolchain fails the build with a message pointing at
-PskipRustEngine, which builds the app without the Rust engine (Java engine at
runtime, so such a build can only start networks on Android 13 / API 33 and newer). The packaged desktop installers also need cargo
(packageDmg/packageDeb/runDistributable fail loudly without it, so an
installed app can always switch engines).
Release artifacts don't rely on committed binaries — CI builds the Rust engine
from source for the APK and the packaged desktop apps.
To actually build the Rust engine and bundle it, per target:
| Target | Requirements |
|---|---|
Desktop dev loop + packaged installers (packageDmg/packageDeb) |
rustc/cargo stable, at least the workspace rust-version in rust/Cargo.toml (the dependency graph sets it; rust-toolchain.toml tracks stable, and the Gradle probe reads that line and skips anything older). Builds the host triple; the x64 dmg CI leg additionally passes -PrustTarget=x86_64-apple-darwin under Rosetta. |
Android jniLibs (cargoNdkAndroid) |
The same rustc/cargo, plus rustup target add aarch64-linux-android x86_64-linux-android, cargo install cargo-ndk, and an Android NDK r28+ (16 KB-aligned LOAD segments for Android 15+; older NDKs work because .cargo/config.toml forces the alignment, and the build fails loudly if it slips). Found via $ANDROID_NDK_HOME or the newest <sdk>/ndk/<version>. |
iOS framework (:app-ios) |
macOS with Xcode 26+ and rustup target add --toolchain stable aarch64-apple-ios aarch64-apple-ios-sim. Without them the framework tasks disable themselves with a warning (a previously built libmyotis_engine.a also satisfies them). |
Regenerating the committed Kotlin bindings (uniffiGenerateKotlin) |
Just rustc/cargo — the pinned rust/uniffi-bindgen CLI builds from the workspace. Re-run after any ffi.rs shape change. |
./gradlew cargoBuildHost # cargo build --release (auto-runs before :app:run / :consensus:test)
./gradlew cargoTest # cargo test --workspace (part of `check`)
./gradlew cargoNdkAndroid # Android jniLibs (needs cargo-ndk + NDK)
./gradlew uniffiGenerateKotlin # regenerate the committed Kotlin bindings after ffi.rs changes
./gradlew :app:run -Pengine=rust # daemon on the Rust engine (hard; default auto)
./gradlew :app-desktop:run -Pengine=java # desktop GUI forced onto the Java engineThe log index is an opt-in, per-network index of the event logs of contracts you choose
— every log verified against receipt roots on the devp2p network (no RPC provider), backfilled
to each contract's deployment block, then kept current at the head. Queries outside what the
index has covered are refused with an error, never answered with a misleading [].
Rust engine only (the default auto selection serves it; forcing the Java engine gives it up).
Open the Index tab (desktop, Android, iOS), enter each contract's address and the block to index back to — its deployment block; when unsure, choose an earlier block: later silently hides the earlier events, earlier merely walks further — then flip Collect logs on. The backfill runs in the background ("Max download speed" trades battery/bandwidth for speed) and the tab shows per-contract progress. Display names resolve automatically via verified reverse-ENS where a name exists.
For a big backfill (months of history), build the index once on a machine that can run around the clock, then hand the file to the apps:
# 1. Start the daemon on the target network (the Rust engine serves the index).
./gradlew :app:run -Pnetwork=gnosis
# 2. Tell it what to index: one or more addresses, and how far back.
./gradlew :app:run -Pnetwork=gnosis -Pargs="build-logindex 0x45a1502382541cd610cc9068e88727426b696293 --from 31305656"
# 3. Watch the backfill (cursor walks DOWN to the target; maxSpeed is on for build-logindex).
./gradlew :app:run -Pnetwork=gnosis -Pargs=logindex-status
# 4. When blocksRemaining reads 0 (equivalently: every watched entry's
# coverage has reached its own from_block — backfillCursor itself can stop
# short of the target once one entry finishes before another): export a
# portable snapshot.
./gradlew :app:run -Pnetwork=gnosis -Pargs="export-logindex /tmp/my-index.db"The exported .db is self-describing and chain-tagged (network id + genesis hash), so it
can only be imported into a node on the same chain. Import it via the Index tab's
"Import log-index snapshot…" button (Settings → Expert mode shows the tab) — the receiving node merges it with whatever it already
holds and immediately starts catch-up for every imported address, so the file does not need
to be fresh. One bound: the head-gap bridge spans at most 500,000 blocks (~29 days on
Gnosis, ~10 weeks on mainnet), so import a snapshot within that window of its export — or
re-export a fresh one, which costs seconds on a node whose index is current.
Display names are deliberately NOT part of the file: the importing wallet resolves them itself (verified reverse-ENS), so a generated file cannot lie about who a contract is.
Key Gradle modules (plus the rust/ Cargo workspace):
- myotis-api -- the engine contract: zero-dependency interfaces every host consumes exclusively
- myotis-engine -- the engine LIBRARY: a Kotlin Multiplatform module (jvm + android, no UI) packaging the Rust engine for the hosts as the Android AAR (jniLibs + JNA's dispatcher inside) and the desktop jar;
io.myotis.engine.Myotisis the entry point. Published aseth.myotis:myotis-engine(GitHub Packages, the token-freemaven-repobranch and a Maven-repository zip on each release) — docs/android-library.md - myotis-engines -- the engine selector (
Engines.engine();myotis.engine=java|rust|auto) routing to the Java engine or the Rust one - myotis-engine-rust -- the Rust engine behind the
:myotis-apicontract on its own, over the committed UniFFI Kotlin bindings + JNA; no Java-engine dependency and no HTTP server (the loopback listener is a port the selector fills) - myotis-rpc-policy -- the JSON-RPC serving policy (
RpcBlockWindow) shared by the Rust engine, the router and the iOS backend; Kotlin Multiplatform, dependency-free - myotis-engine-common --
WakeGate+SleepMetrics, the lifecycle primitives both engines' handles share - myotis-engine-ports -- the engine library's default port actuals (
io.myotis.engine.ports: the node-key file, the file-backed peer caches, theHttpURLConnectionCCIP gateway, the per-network file layout), what a consumer handscreate()when it has none of its own; the Android app's former actuals - node-core -- the Java engine's adapters (
JavaMyotisEngine/JavaChainHandle) plus the verification ladder over the modules below - core -- cryptographic identity (
NodeKey), data types (BlockHeader), ENR decoding - networking -- protocol layers, all Netty-based:
discv4-- UDP peer discovery (ping/pong/findnode/neighbors)discv5-- UDP CL peer discovery (wraps ConsenSys'io.consensys.protocols:discovery, consumed as thecom.github.biafra23:discoveryAndroid fork so it runs at minSdk 29 -- seegradle/libs.versions.toml)rlpx-- TCP transport with EIP-8 ECIES handshake and AES-256-CTR framed channeleth-- eth/66-69 sub-protocol (hello, status, block headers/bodies, receipts, transaction gossip)snap-- snap sub-protocol, snap/1 and snap/2 / EIP-8189 (account range, storage range, bytecode, with Merkle proofs; the two versions share those messages, and a connection runs the highest one both sides offer)
- consensus -- beacon chain light client (sync committee BLS verification), Merkle-Patricia proof verification
- myotis-evm -- Hyperledger Besu EVM running against a SNAP-backed
StateOracle. Powers ENS resolution,eth_call, and local gas estimation (DefaultEvmExecutor.estimateGas— geth's search for the lowest gas limit that succeeds, + 15% safety buffer). IncludesCcipReadEvmExecutorfor ERC-3668 off-chain lookups andPrefetchingEvmExecutor(multi-hop speculative prefetch) to amortize SNAP round-trips. - myotis-ens -- the Java engine's ENS resolver (
EnsResolver,ReverseLookup): discovers each name's resolver through the Registry (ENSIP-10 walk) and calls it directly in the local EVM. Forward and reverse resolution, every record type, ENSIP-10 wildcards, ERC-3668 off-chain records. (The Rust engine has its own equivalent inrust/myotis-net/src/el.) - jsonrpc-server -- host-agnostic verified JSON-RPC router (Kotlin Multiplatform/Ktor).
RpcRoutermaps the Ethereum API onto this module'sRpcBackendseam — implemented on the JVM byVerifiedReadsBackendover theio.myotis.api.VerifiedReadscontract, and on iOS by:app-ios'sIosRpcBackend. Strict permissionless mode by default; binds loopback only. (Consumed by the Android, iOS, and desktop apps and the daemon — which additionally has its CLI/IPC command surface.) - rpc-backend -- the verified RPC backend (
VerifiedRpcBackend): anchored-head building, serve-stale policy, and the readiness probe (verifiedHeadAgeMs) shared by the JSON-RPC server and the hosts - ui -- shared Compose Multiplatform
NodeScreen(the readiness strip, the Status, Query and Settings tabs, and the Logs and Index tabs of Expert mode) used by the Android, desktop, and iOS apps - app -- daemon/CLI entry point, Unix domain socket IPC server, peer caching
- app-desktop -- the Compose desktop GUI over
:ui, packaged with jpackage (dmg/deb), bundling the Rust engine - app-ios -- the iOS host: a Kotlin/Native framework (
MyotisKit) bundling:uiwith iOS seam actuals over the Rust engine's plain C ABI; the Xcode shell lives inios-app/(the JVM engine never runs on iOS) - android-app -- the Android wallet node (
NodeServiceforeground service). Runs the full devp2p + libp2p stack, the local EVM, and the JSON-RPC server on-device, with Android-native peer/snapshot caching and a Compose UI; persists the sync snapshot, the known-state-root window, and light-client-capable peers for fast warm restarts (~10 s vs. a cold checkpoint bootstrap). - rust/myotis-node (Cargo, not Gradle) -- the Node.js (napi-rs) addon over the engine's C ABI, for Electron/Node hosts — the embedding the Freedom browser's experimental Myotis tier consumes
- rust/myotis-wire (Cargo, not Gradle) -- the execution-layer wire protocols (discv4 codec, RLPx, eth/snap messages, snap proof verification) and, behind its
eth2feature, the consensus req/resp codec, without an async runtime:no_std+allocwithout thestdfeature, for hosts with their own event loop (UEFI firmware, wasm).myotis-netre-exports it at the old paths - rust/myotis-rpcd (Cargo, not Gradle) -- a standalone JSON-RPC daemon over the Rust engine's verified reads, for hosts that can't or won't run the JVM (servers, containers, a Bee node's RPC endpoint) — see rust/myotis-rpcd/README.md
DiscV4Service (UDP)
discovers peers
--> RLPxConnector.connect() (TCP)
--> ECIES handshake (HANDSHAKE_WRITE -> HANDSHAKE_READ -> FRAMED)
--> EthHandler (AWAITING_HELLO -> AWAITING_STATUS -> READY)
--> block headers, account/storage queries available
On the Java engine, both discv4 (EL) and libp2p (CL) get their initial peer lists from three sources, merged at startup:
- Hardcoded fallback in
NetworkConfig— a handful of IPv4 bootnodes and CL multiaddrs that ship with the binary. - On-disk cache (
PeerCache,CLPeerCache) — peers that responded successfully on a prior run. The daemon writespeers[-<network>].cache/cl-peers[-<network>].cache(plussync-state[-<network>].snapshot) into its working directory; the Android app keeps the same files in the app's cache dir (getCacheDir()). - EIP-1459 DNS-based ENR trees — each network can list
enrtree://<base32-pubkey>@<domain>URLs inNetworkConfig.elEnrTreeUrls/clEnrTreeUrls. At startup the daemon walks each tree over DNS TXT records, verifies the root record's secp256k1 signature against the embedded pubkey, and decodes the leaf ENRs. Results are merged into the EL bootnode list and the CL peer list. Mainnet currently pins the Ethereum Foundation canonical tree (all.mainnet.ethdisco.net); the resolver is implemented innetworking/dns/DnsEnrResolver.
DNS resolution is best-effort: on timeout, missing TXT records, or signature mismatch the daemon logs a warning and starts up with whatever the hardcoded + cached sources provide. Per-tree deadline defaults to 10 s.
The Rust engine seeds from the same hardcoded bootnodes / CL pins and the same on-disk caches (byte-identical formats, so switching engines keeps them), plus discv4/discv5 and, for the EL, any enode:// seed pins a host hands it (myotis_set_boot_enodes, ABI ≥ 31). Since #539 it walks the same EIP-1459 EL trees too (rust/myotis-net/src/el/dnsdisco.rs) — but only on hosts that allow DNS (myotis_set_dns_discovery, ABI ≥ 39: the desktop app, the daemon and myotis-rpcd, through the system resolver; not Android or iOS, until the engine takes a host's DNS servers, and not yet the Node addon) and never while Tor is enabled. Its walk also checks that every record hashes to its DNS label and verifies each leaf's signature, and the leaves pass the same fork-id filter as discv4's candidates before the pool dials them. Where DNS is off — mobile, Tor — a stale bootnode list still never seeds EL discovery on a fresh mainnet profile (no pinned enodes), which is why the release checklist re-syncs the bootnodes from go-ethereum (CLAUDE.md §Releases).
- Tuweni 2.7.2 (upstream,
io.consensys.tuwenion Maven Central) -- RLP encoding, SECP256K1, byte utilities - Netty 4.2.x (upstream) -- NIO transport. (The earlier Kotlin-transpiled tuweni/netty forks existed to explore a Kotlin-Multiplatform engine; multiplatform now comes from the Rust engine, so the forks were retired.)
- BouncyCastle -- SECP256K1 crypto provider
- jvm-libp2p -- beacon chain P2P networking (consensus module)
- dnsjava 3.6 -- TXT-record resolution for EIP-1459 ENR tree walks (Java engine; the Rust engine uses
hickory-resolver, already part of its libp2p stack)
Myotis is licensed under the Apache License, Version 2.0.
Copyright 2026 Dirk Jäckel.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Myotis shall be licensed under Apache 2.0 as above, without any additional terms or conditions.