Kashimaby STRATO

Kashima: messages between contracts

One endpoint per chain. A contract calls send; on the destination, anyone presents the proof to the endpoint there, and the endpoint calls the receiver.

send(uint256 dstChainId, address receiver, bytes payload) returns (uint256 nonce)
receiver.onKashimaMessage(uint256 srcChainId, address sender, uint256 nonce, bytes payload)

This document is the wire format and the rules. docs/threat-model.md is what the rules are for.

The message

Field Set by Meaning
nonce source endpoint Counts that endpoint's sends from 1. With the source chain, it is the message's identity.
sender source endpoint msg.sender of send. A caller cannot name another sender.
dstChainId caller The chain the message is for. send reverts if the endpoint has no route there.
receiver caller The contract called on delivery. Never zero.
payload caller Opaque. Arrives byte-identical. May be empty.

The source chain is not a field. The destination endpoint knows which chain a proof came from by which light client verified it.

Chain ids are EVM chain ids for EVM chains. A STRATO network's id is fixed when its endpoint is deployed (chainId_) and each EVM endpoint is deployed with the same number (stratoChainId_); the two must agree or nothing delivers.

Topology

STRATO is the hub. An EVM endpoint is paired with exactly one STRATO network and reaches nothing else. The STRATO endpoint has a route per EVM chain. A message between two EVM chains is two messages with a STRATO contract between them.

KashimaRouter is that contract, for any consumer: it has the endpoint's send and delivers through onKashimaMessage, takes a message for any chain the hub reaches, and hands the receiver the chain, sender and nonce the message began with. A consumer points at the router instead of the endpoint and changes nothing else. docs/routing.md.

A route open one way

A route needs a light client in each direction, and some chains give the hub nothing to build one on. HyperEVM is one: its headers carry no state root and no trace of who agreed on them, and what its validators sign is not published. But HyperEVM runs contracts, so the hub's side works there as on any EVM chain: STRATOLightClientV2, the endpoint and the router.

So the route is open towards the chain only. On STRATO it names DeferredLightClient, a light client that has anchored nothing: send works, and no delivery from that chain can succeed, by log or from state. A message sent on the chain towards the hub is accepted by the endpoint there and waits. If the chain ever becomes provable, the deferred client's owner installs the real light client behind it, once, and what waited becomes deliverable. That choice is the power openRoute gives for a chain with no route, held back until there is something to install; an owner who renounces first closes the inbound half for good.

What a consumer can do over such a route: tell a contract there something (a settlement, an instruction). What it cannot do: learn anything from it.

DogeOS is the second such chain, for another reason. Its headers are ordinary and its state can be proven against them, but nothing public says which headers are its own: there is no settlement contract on any chain, and its state is vouched for by its own validators and a TEE signer in a format that is not published. When that changes, a light client can be installed behind its deferred one.

On the wire

In both directions the proven record commits to the payload by its keccak256, and whoever delivers supplies the bytes. The endpoint checks them against the hash.

STRATO -> EVM

The STRATO endpoint emits

MessageSent(uint256 messageNonce, address sender, uint256 dstChainId, address receiver, uint256 payloadHash)
MessagePayload(uint256 messageNonce, bytes payload)

The EVM endpoint proves MessageSent, by name, from the STRATO endpoint's address, with exactly five arguments, in a successful transaction, against the receipts root of a STRATO block its light client has anchored. Then dstChainId == block.chainid and keccak256(payload) == payloadHash.

Why the payload travels as a hash: SolidVM deletes a default-valued event argument from the proven receipt and shifts the rest left (DIALECT.md), so every proven argument must be non-zero by construction. The nonce counts from 1, the sender is the caller, a route exists only for a non-zero chain, the receiver is checked, and a keccak256 is non-zero. bytes has a default (the empty payload), so it stays out of the proof; and a proven log then carries only integers and addresses, the two types whose receipt encoding has been exercised against a live node.

MessagePayload is for whoever delivers. It is not proven and not trusted. ts reads the payload from it and checks the hash before using it; a caller that knows the payload can pass it instead.

The STRATO endpoint also stores

sentHash[nonce] = uint256(keccak256(abi.encode(nonce, sender, dstChainId, receiver, payloadHash)))

the same hash the EVM endpoint stores for its own sends. STRATO keys a contract's storage by the keccak256 of a variable's path as text, so the entry is at keccak256("sentHash[<nonce>]"), the nonce in decimal, and its value is the RLP list [0x00, n]. The variable's name is part of the wire format.

The EVM endpoint accepts either of two proofs of the same send:

deliver deliverFromState
Proves the MessageSent log the sentHash entry
Against the receipts root of the block the send is in the state root of any anchored block at or after the send
Light client must keep receipts roots (both do) state roots (STRATOLightClientV2 does; the deployed STRATOLightClient does not)
Anchoring per message the send's own block (about 230,000 gas) none, if a later block is anchored
Delivery itself about 260,000 gas with a small receipt about 230,000 gas plus the receiver, with a six-node account proof
Needs of the STRATO node the receipt-proof route (every node) the state-proof route (GET /state/proof; on helium, one node so far)

deliverFromState proves two tries: the STRATO endpoint's account under the header's state root (key keccak256(address), value the account record [nonce, balance, contractRoot, codeHash]), then the entry under the account's contract root. evm/contracts/lib/StratoStateProof.sol has the shapes, and its tests run on proofs a node really served.

Both proofs spend the same nonce.

EVM -> STRATO

The EVM endpoint emits

MessageSent(uint256 indexed nonce, address indexed sender, address indexed receiver,
            uint256 dstChainId, bytes32 payloadHash, bytes payload)

and stores

sentHash[nonce] = keccak256(abi.encode(nonce, sender, dstChainId, receiver, payloadHash))

at storage slot keccak256(abi.encode(nonce, 2)). The slot index is part of the wire format.

The STRATO endpoint accepts either of two proofs of the same send:

deliver deliverFromState
Proves the MessageSent log the sentHash slot
Against the receipts root of the block the send is in the state root of any anchored block at or after the send
Light client must anchor receipts roots (all do) state roots (EthLightClient does)
Anchoring per message the send's own block none, if a later block is anchored
Cost grows with the size of the sending transaction's receipt nothing the sender controls

Prefer deliverFromState wherever the light client supports it. A STRATO transaction has a fixed gas budget (400,000) and a log proof walks the whole receipt, so a send buried in a transaction with many logs can cost more to deliver than the budget allows. A storage proof does not have that failure. It also turns anchoring from a per-message cost into a periodic one.

Both proofs spend the same nonce.

Whether a route can deliver from state is a property of its light client, not of the route: ILightClient declares receipts roots only, and state roots are a second interface, IStateLightClient, that a client either has or does not. EthLightClient has it; so do BaseLightClient, for the block a dispute game's claim commits to, ArbitrumLightClient, for each confirmed block, and LineaLightClientV2, for each block it anchors. The imported Linea and BSC clients do not, and deliverFromState through one of them reverts.

Anchoring STRATO on an EVM chain

STRATOLightClientV2.submitHeader takes a header and its validators' seals and keeps the block's receipts root and state root. With delivery from state, one anchored header serves every message sent up to that block, so a relayer anchors the newest block when it has messages waiting and nothing when the light client's tip is already past them (evm/scripts/e2e/relayFromStratoState.js does exactly that). A block of a validator set the client has moved past comes in by anchorAncestors.

A node asked about a block seconds old has been seen to serve a header that does not hash to the block's hash (its stake list in another order), and the real header a moment later. The node's maintainers traced it to an unordered database read behind every route that rebuilds a header; the fix is on one helium node so far, so the receipt-proof route of the others still has the window. ts checks the hash and reports a mismatch as retryable; a header that does not hash to what the validators sealed anchors nothing.

Anchoring Ethereum on STRATO

deliverFromState needs one thing from the light client: the state root of some Ethereum block at or after the send. The cheapest block to anchor is the one Ethereum has just finalized, because the light-client finality update already carries everything that proves it:

EthLightClient.anchorFinalizedHeader(headers, sync, eph, executionBranch)

No parent-hash walk, no beacon state proof, and one anchor serves every message sent before it. ts builds the call from the finality update alone (buildFinalizedAnchorInputs, finalizedAnchorCall). The older entrypoints (anchorBlockHeader with a parent chain, anchorBlockHeaderViaBlockRoots, ...ViaHistoricalSummaries) anchor a specific older block and are what the log path needs, since a log is proven against its own block.

Every anchor, and every committee advance, verifies a sync-committee signature, and for that the light client needs the sum of the public keys of the members who signed. Summing them inside the anchor does not fit a STRATO transaction (about 9,000 gas per absent member against a 400,000 budget, and a few dozen are usually absent). The sum is therefore put in a cache, keyed by (period, participation bitfield), before the anchor runs. There are two ways to fill it, and the anchor does not care which did:

buildAggregate submitAggregateProof
Who computes the sum the light client, from the committee it holds proverd, off chain
What is trusted nothing new the PLONK verifier, its setup, and the committee commitment
Transactions one per ~28 absent members (one if nobody is absent) one
Needs off chain nothing a prover and its SRS
Needs on chain the committee's own aggregate key (stored by bootstrap and by every advance) a verifier and a commitment per period

buildAggregate is the default in strato/contracts/e2e/lightclient.js (--route chunks); --route prover takes the other. A light client with no verifier set has only the first, and that is a complete light client.

Anchoring Base on STRATO

Base is an OP-Stack rollup: it posts its state to Ethereum, so its light client on STRATO is built on the Ethereum one. A dispute game is created on L1 for a Base block, about every twenty minutes on Base Sepolia, and its root claim is

keccak256(bytes32(0) || stateRoot || messagePasserStorageRoot || blockHash)

four 32-byte words. BaseLightClient proves the DisputeGameCreated log against an L1 block EthLightClient has anchored, checks the claim against the Base header it is handed, and keeps that block's receipts root and state root. A message sent on Base is then delivered from state at that block:

send on Base at block S
wait for a game claiming a block G >= S, and for its L1 block to be final   (about an hour)
EthLightClient: anchor that L1 block                                         lightclient.js anchor --tx
BaseLightClient.anchorBaseBlockFromGame(...)                                 baselightclient.js anchor --min-block S
Kashima.deliverFromState(84532, G, ...)   with eth_getProof taken at G

ts's findBaseAnchor finds the game and builds the call. The log path works too, where a game's block is within 256 blocks after the send (anchorBaseBlockChainViaCannon walks parent hashes down from it), which a message cannot arrange for.

What this trusts. The contract takes the claim of any game that was created. It does not check that the game resolved in the claim's favour, so it is as honest as whoever can create a game, and on a chain where creating one is permissionless that is anyone with the bond. This is the imported design, it is not good enough for value, and it is the first thing to fix before this route carries any (threat-model.md).

Anchoring an Arbitrum chain on STRATO

An Arbitrum chain (Robinhood Chain is one) also posts to Ethereum. Its rollup contract on L1 emits AssertionConfirmed(assertionHash, blockHash, sendRoot) once an assertion's challenge period has passed, and blockHash is the hash of an L2 block whose header is an ordinary Ethereum header. ArbitrumLightClient proves that log against an L1 block EthLightClient has anchored, takes the L2 header that hashes to it, and keeps the block's state root, receipts root and the send root. It has no owner. Unlike the Base client it takes only what the rollup has confirmed, so it trusts the chain's own fraud-proof assumption and whoever can upgrade its rollup contract, and nothing else.

send on the chain at block S
wait for an assertion to be confirmed for a block G >= S, and for its L1 block to be final
EthLightClient: anchor that L1 block                          lightclient.js anchor --tx
ArbitrumLightClient.anchorConfirmedBlock(...)                 arbitrumlightclient.js anchor --min-block S
Kashima.deliverFromState(chainId, G, ...)   with eth_getProof taken at G

On Robinhood Chain Testnet an assertion is created about every half hour and confirmed twenty minutes later, for a block that is by then over an hour old. That is the catch: the storage proof has to be taken at G, and a node that is not an archive node has dropped G's state long before G is confirmed (the public Robinhood RPC keeps under a minute). Either use an archive RPC, or take the proof while G is new. G is always the last block of a sequencer batch, and which batch is fixed when the assertion before it is created, so a relayer that takes a proof at the end of every batch as it lands on L1 has the one it will need. ts's findArbitrumAnchor finds the confirmation and builds the anchor call.

Anchoring Linea on STRATO

Linea is a zk rollup, and what it finalizes on L1 is the root of its own state tree: a sparse Merkle tree of depth 40 hashed with Poseidon2 over the KoalaBear field. Not a block hash, and not the state root in a block header, which is a different root of a different tree. So there is nothing on L1 to compare a Linea header with directly. LineaLightClientV2 goes through the state instead:

1. anchorFinalization    the rollup's DataFinalizedV3 log, proven against an anchored L1
                         block: the state root Linea proved for block E
2. beginHistory, stepHistory...
                         the account of the EIP-2935 history contract under that root:
                         the root of its storage at block E      (once per finalization)
3. beginBlock, stepBlock...
                         slot N mod 8191 under that storage root: the hash of block N.
                         The header that hashes to it is anchored.       (once per block)

Every chain since Prague keeps its last 8191 block hashes in that contract, so any block of a finalized range is reachable from the range's last state. An anchored block has its receipts root and its state root, and both of the endpoint's delivery paths work on it unchanged: deliver with the send's own block anchored, deliverFromState with an eth_getProof taken at the anchored block (take it at the send's own block, when the send happens).

A proof in Linea's tree is 80 Poseidon2 permutations and more, and SolidVM runs each as several hundred ordinary statements: 22 fit one transaction's gas (measured on helium). So each proof is a walk. A call takes the next few sibling hashes and moves the walk up; the call that reaches the top compares the root. Nothing is trusted between calls, a walk belongs to the caller who began it, and a walk fed a wrong sibling ends at the wrong root and anchors nothing. Five calls for the history contract, five for a block. ts fetches linea_getProof, checks it against the finalized root, and lays the calls out (findLineaFinalization, getLineaBlockProof, lineaHistoryCalls, lineaBlockCalls); linealightclient.js anchor --block N runs them.

Linea Sepolia finalizes every three to five hours, which is the delay of this direction. There is also a deadline. A Linea node that is not an archive serves linea_getProof for the newest finalized state only, and that state holds a block's hash only while the block is within 8191 of it. So a block can be anchored from the finalization that first covers it until it is 8191 blocks behind the newest one: about half a day on Linea Sepolia. After that its hash is still in older finalized states, and proving it needs a node that kept them. findLineaFinalization picks the newest finalization that still covers the block. It trusts the Ethereum light client and Linea's validity proof as its rollup contract checks it, including whoever can upgrade that contract; the client itself has no owner.

A SolidVM builtin for this Poseidon2 (the node has one for other parameters) would turn ten transactions into one or two.

Delivery

  • Permissionless. Anyone with the proof may deliver. Nothing is paid to the endpoint and nothing is required of the caller.
  • Exactly once. Keyed on (source chain, nonce). Not on where a log sits, not on which proof was used.
  • Unordered. Nonces arrive in any order. A consumer that needs order enforces it with the nonce it is handed.
  • A revert keeps the message. If the receiver reverts, the delivery reverts and the nonce is not spent. If the receiver returns, the message is consumed, whatever the receiver did with it.
  • Never expires. A message can be delivered at any time after it is sent.
  • Re-entrant on purpose. A receiver may call send from inside its own delivery (a hub forwarding to the next hop does). The nonce is spent before the receiver is called.

What a receiver must do

KashimaReceiver does the first two. The third is the consumer's.

  1. Require the caller to be the endpoint.
  2. Require (srcChainId, sender) to be a peer. Anyone can send a message to any receiver. A message from a stranger is as genuine as any other.
  3. Refuse, by reverting, anything it cannot act on now. A handler that swallows a failure has consumed the message. If acting on a message means calling something that reports failure by returning rather than reverting, check its preconditions first and revert yourself. PortalKashimaAdapter does this for the portal's hook, which never reverts.

On an EVM chain a handler that catches its own failures should also check gasleft() on entry: a delivery sent with too little gas can make an inner call fail and be caught, consuming the message.

Rules for consumers that hold value

Mint or release from the consumer's own address. The endpoint holds no funds and no roles. Whatever allowlists a bridge allowlists the consumer, and whatever budget bounds a bridge bounds the consumer. The portal adapter mints PortalToken itself for this reason.

Never settle a message out of band. A message is valid forever and its delivery is permissionless. If the source side refunds or releases what a message stands for while the message is still deliverable, the delivery later pays against backing that is gone. The only safe refund is one the destination authorizes: the message is delivered, the destination consumer decides to refuse it (expired, cancelled, over a limit), records that, and sends a message back; the source releases on proof of that message. An operator's list of "known bad" deposits is not a defence, because the operator is not the only one who can deliver.

Bound what a delivery can release. Every proof system has a soundness assumption. A per-consumer, per-asset, per-window budget turns a broken assumption from a drained vault into a bounded loss. The core does not impose one because it does not know what a payload is worth.

Keep amounts, scales and thresholds out of the payload's interpretation by the core. There is none. A value that goes in comes out byte-identical; a "uint256 amount" that is really a 248-bit message survives because nothing in between treats it as a quantity.

Hooking an asset bridge to Kashima

MercataBridge and StratoNativeBridge are consumers. Each needs an adapter per side, shaped like portal's:

  • On STRATO, the adapter is the address MercataBridge.setBridgeIn(srcChainId, adapter) names. Its onKashimaMessage decodes the deposit and calls creditTrustlessDeposit(...); the target trusts its caller completely, so the adapter is where the peer check lives. Outbound, the bridge (or the adapter on its behalf) calls send with the withdrawal encoded as the payload instead of relying on its own event's shape.
  • On the EVM chain, the adapter is the vault's authorized releaser. It decodes the withdrawal, applies the vault's thresholds and decimal scaling, and releases. Inbound deposits call send from the router.

Payloads are abi.encode of fixed-width fields. SolidVM's abi.encode matches solc's for integers and addresses and NOT for bytes32 (DIALECT.md section 4), so carry hashes as uint256.

Off chain

// STRATO -> EVM, from the log (the send's own block must be anchored)
const m = await findStratoMessage(node, txHash, stratoEndpoint);
await ensureAnchoredV2(lightClient, node, m.blockNumber);
await evmKashima.deliver(...stratoDeliveryArgs(m));

// STRATO -> EVM, from state: any anchored block at or after the send
const { message } = await findStratoMessage(node, txHash, stratoEndpoint);
const p = await getStratoMessageStateProof(node, stratoEndpoint, message, block);  // latest if no block
await ensureAnchoredV2(lightClient, node, p.blockNumber);
await evmKashima.deliverFromState(...stratoStateDeliveryArgs(message, p));

// EVM -> STRATO, from state
const m = await readEvmMessage(rpc, txHash, evmEndpoint);
const p = await getEvmMessageStateProof(rpc, evmEndpoint, m, anchoredBlock);
await stratoKashima.deliverFromState(...evmStateDeliveryArgs(srcChainId, m, p));

// Anchoring the block Ethereum has just finalized, with no prover. Each
// call is one STRATO transaction; anchoredBlock above is a.blockNumber.
const a = await buildFinalizedAnchorInputs(srcChainId);
const period = Math.floor(Number(a.sync.signatureSlot) / 8192);
const calls = [...buildAggregateCalls(period, a.sync.participationBits, 28), finalizedAnchorCall(a)];

// EVM -> STRATO, from the log (the send's own block must be anchored)
const m = await findEvmMessage(rpc, txHash, evmEndpoint);
await stratoKashima.deliver(...evmDeliveryArgs(srcChainId, m));

The storage proof has to be taken at the anchored block, and most public RPCs serve eth_getProof only for the newest few blocks, while a finalized block is about seventy old. Either use an RPC with a longer proof window, or take the proof when the block is produced and keep it until the block is final: Ethereum finalizes epoch-boundary blocks, so the block to prove at is known in advance (evm/scripts/e2e/checkpointProof.js).

On STRATO, deliver and deliverFromState take bytes arguments and must be submitted by the signer directly; bloc's wallet path stringifies bytes (DIALECT.md section 2). A mangled argument fails its hash or its proof.

Replacing a light client or an endpoint

Nothing in an endpoint can be changed, so nothing in it can be taken over. The cost is that a fix is a new deployment:

  • A new light client means a new endpoint bound to it.
  • On STRATO, a new EVM endpoint or light client for a chain means a new STRATO endpoint, since a route cannot be re-pointed.
  • Each consumer decides whether to follow. A consumer that others allowlist keeps its own address and moves its endpoint pointer (KashimaReceiver._setKashima, exposed by PortalKashimaAdapter.setKashima).
  • Messages undelivered on the old endpoint are refused by a consumer that has moved. Consumers that cannot resend (portal can, with retry()) should drain the old endpoint before moving.

Routing: messages between chains that share no link

Status: built and tested in both dialects (KashimaRouter); not yet deployed anywhere. It moves multi-hop forwarding out of the portal and into Kashima, so that an application names a destination and never a next hop. The design was agreed with the portal and the open choices were made by Dustin on 2026-10-02 (see "Decided").

What is there today

An endpoint is a link: an EVM endpoint reaches one STRATO network and nothing else, and the STRATO endpoint has a route per EVM chain (docs/kashima.md, Topology). A message between two EVM chains is two messages with a STRATO contract in between, and today that contract is the application's own: the portal forwards its own settlements, with its own route table, hop budget, bounces and retries.

The links into the hub exist for every chain the portal cares about except one. On testnet, in both directions, delivered by the relayers:

Chain How STRATO learns its blocks First leg into the hub waits for
Ethereum (Sepolia) sync-committee light client Ethereum finality, then the next finalized anchor
Base (OP Stack) a dispute game's claim, proven in an Ethereum block a game covering the block, then Ethereum finality
Robinhood Chain (Arbitrum Nitro) a confirmed assertion, proven in an Ethereum block the assertion's confirmation
Linea the state root Linea finalizes on Ethereum Linea's finalization

All three rollups are proven through the one Ethereum light client on STRATO. Each has a small contract of its own there, because each rollup says "this is my block" to Ethereum in a different way, but none of them adds a set of signers to trust. So the unlock the portal asks about is done for OP-Stack chains, Arbitrum chains and Linea.

It does not extend to HyperEVM. HyperEVM does not settle to Ethereum, so there is nothing on Ethereum to prove its blocks from. It needs a light client of its own consensus on STRATO, and I have not looked at what its validators sign or whether that is checkable in a contract. Until someone does, HyperEVM stays on the relay.

Out of the hub there is one mechanism for every chain: STRATOLightClientV2 on the destination, which takes a STRATO header on the signatures of a stake-weighted quorum. It is quick (one header, one delivery).

The router, separate from the endpoint

KashimaRouter, one per chain. It sits on top of the endpoint and looks like an endpoint from above:

application
    |  send(dstChainId, receiver, payload)          onKashimaMessage(srcChainId, sender, nonce, payload)
KashimaRouter        implements IKashima to the application, IKashimaReceiver to the endpoint
    |  send(nextChainId, router there, envelope)     onKashimaMessage(linkSrc, router there, linkNonce, envelope)
Kashima endpoint     unchanged

An application chooses by where it points its kashima address. At the endpoint: direct links only, as now. At the router: any chain the hub reaches. KashimaReceiver needs no change, and neither does a consumer built on it: send and onKashimaMessage are byte-identical. The portal peers with the origin portal and sends (version, intentId, payoutTo) to it.

What the application sees

onKashimaMessage(srcChainId, sender, nonce, payload) where

  • srcChainId is the chain the message was first sent on,
  • sender is the contract that called the router's send there,
  • nonce is the number the origin endpoint gave the first leg, which is also what the router's send returned to the sender,
  • payload is what was sent.

The hub never appears. A message that crosses the hub and one that does not look the same.

The envelope

What a router sends to the next router, as the endpoint's payload:

first leg   abi.encode(uint256 1, address sender, uint256 dstChainId, address receiver) ++ payload
forwarded   abi.encode(uint256 2, uint256 originChainId, uint256 originNonce) ++ <the first-leg envelope>

A fixed header and then the payload's own bytes, not one abi.encode with a bytes field: SolidVM has no abi.decode and reads words by hand, and a fixed header is the same bytes in both dialects (the SolidVM tests check the hub's envelopes against ones written by ethers).

On its first leg an envelope does not say where it began, because the link already proves that: the endpoint hands the receiving router (linkSrc, linkNonce), and nothing the sending router could write is as good. When the hub forwards, it wraps the envelope it was handed, untouched, in those two words. So the hub reads one word of a message it passes on (the destination) and copies nothing, whatever the payload's size.

The rules

send, called by an application:

  1. On an EVM chain everything goes to the hub's router, since the hub is all the endpoint reaches. On the hub, to the router of dstChainId; revert if there is none.
  2. endpoint.send(next chain, router there, first-leg envelope) with sender = msg.sender. Return the endpoint's nonce.

onKashimaMessage, called by the endpoint:

  1. The caller is the endpoint, and (linkSrc, linkSender) is the router registered for linkSrc. Otherwise revert.
  2. The origin of a first-leg envelope is (linkSrc, linkNonce). The origin of a forwarded one is what its wrapper says, and only the hub forwards: an EVM router takes envelopes from the hub's router alone, and the hub's router refuses an envelope that is already wrapped.
  3. If dstChainId is this chain: mark (originChainId, originNonce) delivered, reverting if it already is, and call receiver.onKashimaMessage(originChainId, originSender, originNonce, payload).
  4. Otherwise forward, which only the hub's router does and only for a message on its first leg: endpoint.send(dstChainId, routerOf[dstChainId], wrapper ++ envelope). It refuses to send a message back to the chain it came from. A router that is not the hub reverts here.

That is the whole of it. There is no route table beyond "direct, or via the hub", no retry and no bounce.

What the portal asked for, point by point

  1. End-to-end sender identity. Yes, as above. Exactly-once is keyed on (origin chain, origin nonce) at the destination router (rule 3). It would hold without that map, because each link is exactly-once and the hub forwards once per delivery, but the map makes it a property of the destination alone instead of a property of three contracts, for one storage write. The map is also what answers isDelivered(originChainId, originNonce) on the router, which the portal's keeper wants as a view: public RPCs will not scan old logs.

  2. Topology. One hub. See the table above for what reaches it.

  3. Deliver and re-send in one transaction. It cannot be otherwise: forwarding happens inside the hub's delivery (rule 4). Either both happen or the delivery reverts and the message is still deliverable at the hub. There is no half state. The relayers need no change either: the messenger delivers every MessageSent it sees, and the hub's re-send is one.

    Paying whoever delivers is not part of this proposal. Nothing in Kashima pays a relayer today, on any leg, and I would not put a fee market in the routing layer. See "Left out".

  4. Loops. There is no hop budget because there is nothing to budget: only the hub forwards, and only a message on its first leg (rule 4), so a message crosses at most two links. Two hubs that prefer each other cannot happen with one hub. If a second hub ever exists, the envelope's version is where a hop count goes.

  5. Thin transport. Unordered, exactly-once, no fragments, no retries. Size: a payload is at most maxPayload bytes, checked in send on every router (the endpoints themselves have no cap). It starts at 1200; the owner may set it up to 8192, and sets it alike on every router, since every leg a message may take must carry it. Measured on helium (2026-10-09): STRATO charges every transaction its whole gas limit (32M) and meters nothing smaller, so gas is not the bound; a message ending on the hub costs time linear in its size (SolidVM copies the payload out of the envelope a byte at a time: 5 ms at 1200 bytes, 34 ms at 8192), and passing one on costs ~2 ms whatever the size. The tighter bounds are Solana's: a message arriving there is staged in a 10 KiB buffer with its receipt proof (about 6 KiB of payload), and one leaving it is carried by CPI (10 KiB) with kashima_router's own cap, 1200, a constant changed by upgrading the program. The envelope adds 128 bytes on the first leg and 128 more when forwarded.

  6. Failure. A receiver that reverts on the last hop reverts the router's call and the endpoint's delivery: the message stays deliverable at the destination endpoint. At the hub, the only thing that can fail is the forward (no route to dstChainId), which reverts the delivery and keeps the message at the hub. No router catches anything, so nothing can be consumed without being handed on.

    One consequence to accept: a message for a chain the hub does not reach is accepted at the origin (a spoke cannot see the hub's routes) and then waits at the hub until the route opens. It is not lost and it is not returned. The portal accepts this: to a solver it reads as in flight, which is what it is.

    The hub cannot announce such a message with an event, because the delivery that would emit it is the one that reverts. Holding the message in the router instead (consume it, store it, release it later) would give an event at the cost of a queue on the transit hop, which is the state this design exists to avoid. What an operator has instead: the origin router's Routed(nonce, sender, dstChainId, receiver) event on every send, reaches(dstChainId) on the hub router as a view, and the messenger's status page, where the message shows as waiting with the revert reason.

Configuration, and who can change it

  • On an EVM chain the router is fixed at construction, like the endpoint: its endpoint, the hub's chain id and the hub's router. No owner.
  • On STRATO the router has the endpoint's shape: an owner who can add a route (chainId -> router, where the endpoint has a route) and cannot change or remove one. That is the same power, held the same way, as openRoute on the STRATO endpoint.

Deployment order follows: hub router (e2e/kashima.js deploy-router), then each chain's router, which names the hub's (evm/scripts/deployRouter.js), then the hub's route to it (e2e/kashima.js open-router-route).

Why separate, and what I would not claim for it

I agree with the portal's recommendation. The reasons I would give, in order of weight:

  1. Nothing proven changes. The endpoints, both proof routes in both directions, the light clients, the proof library and the relayers stay as they are. The embedded variant needs a second kind of record at the STRATO endpoint ("a message I am passing on, which began elsewhere"), and the EVM endpoint needs to prove that record by log and from state. That is new wire format in the part that is hardest to get right.
  2. An application can decline. One that must never transit points at the endpoint and is unaffected by any of this.
  3. It can be replaced without re-linking the links. A routing mistake is a new router and applications moving their pointer, not new endpoints and new light-client pairings.

Where I would correct the portal's framing:

  • It is not only coupling; the trust surface does differ. Embedded, the origin is asserted by contracts every consumer already trusts. Separate, an application that uses routing trusts three more contracts: the origin router to name the sender truthfully, the hub router to copy the origin faithfully, its own router to hand over once. They are small, and on the EVM side immutable, but they are additional, and the hub router has an owner who can add routes. That is the price of points 1 to 3 and I think it is worth paying; it should be said plainly in the threat model.
  • Gas is not second-order on the destination. Rough expectation, not measured: the router adds a call, a decode and a storage write to each delivery, about 30,000 to 35,000 gas on an EVM chain, plus up to 224 bytes of calldata. On STRATO it adds statements to a transaction that already carries a proof, inside the 400,000 budget; that needs a measurement on a node before anything is promised.
  • Now is when embedding is cheapest, since no endpoint is on mainnet and re-linking costs nothing yet. I still would not embed, for reason 1, but the option closes at the rollout.

What routing does to the threat model

A message from A to B through the hub is as good as the weaker of two things: A's light client on STRATO, and STRATO's light client on B. An application on B that lists a peer on A is exposed to A's light client, exactly as if A had a direct link to B.

That makes one known gap wider. BaseLightClient takes the claim of a dispute game whether or not the game has resolved (docs/threat-model.md). Today a false Base block can only reach receivers on STRATO. With routing it reaches every chain, addressed as if from the Base portal. Hardening that client is a precondition for routing value out of Base on mainnet, not a follow-up.

Latency

The second leg is quick. The first leg is as slow as the origin chain is to become provable on STRATO, and for a settlement that is the time a solver's capital is out.

Seen on testnet on 2026-10-02, one message each, sent and delivered by the relayers: Sepolia to STRATO took 30 minutes. Base Sepolia took more than an hour (games there trail the chain by about that). Robinhood and Linea testnets were still waiting when this was written.

Mainnet will be slower where a rollup is optimistic, and how much slower is a decision: an OP-Stack claim can be taken once its game exists (minutes to hours, trusting that a false claim would be challenged) or once the game has resolved (days). An Arbitrum assertion is confirmed only after its challenge period (about a week on Arbitrum One; I have not checked what Robinhood Chain will run). Linea is hours. These figures are from memory of each chain's parameters and should be checked before anyone plans on them.

What decides whether routed settlements are usable on mainnet is not the router, which is small and needs no proof work. It is which finality each origin's light client accepts, and that is decided: resolution (see "Decided"). A settlement out of an optimistic rollup takes days to reach the hub.

Left out

  • Relayer fees. No leg pays its deliverer today. If third-party relayers are wanted, the place for it is above routing: a contract that escrows a fee at the origin and releases it on proof of delivery, which is itself a message back. It needs no change to the router or the endpoint.
  • More than one hub, and paths longer than two links.
  • Order, expiry, bounces, retries. A consumer that needs any of them builds it on the nonce it is handed, as now.

Decided

By Dustin, 2026-10-02:

  1. Routed settlements out of optimistic rollups wait for resolution. A message from an OP-Stack or Arbitrum chain is provable on the hub once its dispute game has resolved or its assertion is confirmed, not before. Days, not minutes.
  2. The Base light client is hardened before anything is routed out of Base on mainnet. BaseLightClient must stop taking the claim of an unresolved game. Not done yet.
  3. The router is built now. This document's contracts.

With the portal:

The portal has read this and accepts the design: a separate router, the application-facing interface unchanged, origin identity end to end, waiting at the hub rather than bouncing, the destination router's delivered map and its isDelivered view, and no fees in this layer (a solver posts every leg of its own settlement). Its peer table stays as it is: the router replaces the endpoint in the portal's setKashima and nothing else moves.

Open

  1. The hub's gas, measured on a node: a from-state delivery plus forward, and a delivery that ends on the hub with a payload at the cap.
  2. Hardening BaseLightClient (decision 2).
  3. A testnet deployment and a run through the hub between two EVM chains.
  4. HyperEVM: what a light client of its consensus would take.
  5. Who owns the hub router's routes on mainnet, and whether it is the same key as the endpoint's.

Threat model

What an attacker can try against Kashima, what each attempt runs into, and what is still taken on trust. The last two sections are the ones to read before deploying: what is assumed, and what has not been verified.

A delivery is valid when a light client says so. Everything below is either a way to make a light client say something false, a way to get an endpoint to misread something true, or a way to stop something true from landing.

1. Forging a message

Attempt What stops it
Deliver a message nobody sent The endpoint acts only on a log or storage slot proven against a root its light client anchored. No key, role or signature substitutes for the proof.
Send as someone else The source endpoint stamps msg.sender. The sender is in the proven record, not in the caller's arguments.
Emit a look-alike log from another contract The emitter must be the one endpoint the destination is bound to: immutable on EVM, written once per route on STRATO.
Hide a look-alike log inside another log's data Logs are found by walking the receipt's RLP from the top to the stated index, never by offset.
Another event of the same shape from the endpoint The event is matched by name (STRATO logs) or by topic0 (EVM logs). The endpoints emit nothing else of that shape.
Change the payload in transit The proven record carries its keccak256; the delivered bytes must hash to it.
Redirect to another receiver, or change the sender Both are in the proven record. For a state proof, the delivering caller states them and the proof shows the endpoint stored the hash of exactly that statement.
Use the logs of a failed STRATO transaction A STRATO receipt with status != 1 is refused. (A reverted EVM transaction has no logs and no storage writes.)
Exploit SolidVM dropping a zero argument, so fields shift Every proven argument is non-zero by construction, and the EVM endpoint requires exactly five. A shortened log is refused, not read out of position.
Present a message for another chain STRATO -> EVM: the message names its destination chain and only that chain accepts it. EVM -> STRATO: the message names the STRATO network; a log for another network fails the check, a storage proof for another network hashes differently.
Present a storage proof of some other contract The account proven is the route's endpoint, by address, against the anchored state root.
Prove an unsent nonce from state An absent slot has no inclusion proof; the proof must end in the message's hash.

From state, in either direction. The destination recomputes the hash of the message the caller states and proves the source endpoint stored exactly that hash under that nonce. A different sender, receiver, payload or destination chain is a different hash; another contract's storage is under another account; a block before the send has no entry. What is proven is storage of the endpoint's own address, so nothing another contract writes, whatever it names its variables, can stand in for it.

2. Replaying a message

Attempt What stops it
Deliver the same proof twice Keyed on (source chain, nonce), set before the receiver is called.
Deliver the same message through the other proof path, or from another anchored block Same key. One nonce, one delivery.
Re-enter deliver from the receiver The nonce is already spent; the inner call reverts and takes the outer one with it.
Replay on a redeployed endpoint A new endpoint has a new address; the far side is bound to one remote address. A consumer that moves its endpoint pointer refuses the old endpoint from then on.

2a. Through the router (KashimaRouter)

A consumer that points at a router instead of an endpoint gets messages from chains its own chain has no link to (docs/routing.md). What it adds to what that consumer trusts:

  • Three more contracts. The router where the message began names the sender (msg.sender of its send). The hub's router says where a forwarded message came from and under what nonce. The consumer's own router hands each message over once. A fault in any of them can forge or lose a message for consumers that point at a router; none can touch a consumer that points at an endpoint. A gen-1 EVM router cannot be changed, and the gen-1 hub router's owner can only name the router of a chain that has none, once. A V3 router (either dialect) has an owner who adds a peer router, which is trusted after the endpoint's activation delay, lists next-hop candidates, and marks a peer transit after the same delay; the owner or a guardian drops a peer or its transit at once.
  • The weaker of two light clients. A message from A to B through the hub is as good as A's light client on STRATO and STRATO's light client on B, whichever is worse. A consumer on B that lists a peer on A is exposed to A's light client as if A had a direct link to B.
Attempt What stops it
Send an envelope to a router directly through the endpoint, claiming another sender or origin A router takes envelopes only from the registered router of the chain the link proves they came from. Anyone else's message to a router is refused.
Claim an origin on the first leg A first-leg envelope has no origin field. The origin is the chain and nonce the endpoint proved.
Have a chain other than the hub vouch for an origin Gen 1: an EVM router's only link is to the hub, and it accepts a forwarded envelope only from the hub's router; the hub's router refuses an envelope that is already wrapped. V3: a forwarded envelope is accepted only from a transit peer (next row but one).
Make a message circle Gen 1: only the hub forwards, only a first-leg envelope, and never back to the chain it came from: two links at most. V3: every router forwards, never back by the link a message came in on and never to the chain it began on, and an envelope counts its hops: more than 8 is refused.
Deliver a forwarded message twice Each link is exactly-once, and the destination router keeps its own record keyed on (origin chain, origin nonce), set before the receiver runs.
Forward through a router that trusts a weak lane, so a committee's or one key's word reaches a chain as "from Ethereum" (KashimaRouterV3) A V3 router cannot check where a forwarded envelope began, only who handed it over. So it takes a forwarded envelope only from a peer its owner marked transit (allowTransit, after the endpoint's activation delay; denyTransit by the owner or the guardian at once; removePeer clears it). From every other peer it takes first legs only, whose origin the endpoint proved. The hub marks transit the routers reached over a light client that forwards (Sepolia and the rollups), never the committee and attested lanes (DogeOS, Solana), so what comes over those lanes can speak only as its own chain. Found in the 2026-10-15 audit; before it, the Solana attestation key could have forged a message "from Sepolia" to any chain through the hub.
Consume a message at the hub without sending it on Forwarding is inside the endpoint's delivery. If the hub has no route for the destination the delivery reverts and the message stays deliverable at the hub.
Strand a message with a payload the hub cannot afford send refuses more than the router's cap: 1024 bytes on a gen-1 router; maxPayload on a V3 router, 1200 to begin with, which the owner may raise to 8192 and sets alike on every router, since every leg must carry it.

3. Making a light client lie: STRATO headers on EVM (STRATOLightClientV2)

Attempt What stops it
A majority of validators holding a minority of stake sign a header The quorum is weighed by stake exactly as the node weighs it (voteWeights, hasSupermajority). STRATOLightClient (V1) counts heads and would accept this.
Forge the stake table or the validator list inside the header A header must list exactly the set and weights the client derived from earlier headers.
Skip a set change The next header lists a set the client does not track and is refused until the changing header is submitted.
Pick the quorum rule by picking the header version Version 3 is required from the staking activation block, version 2 before it.
Count one seal twice, or seals from outsiders Signers must be strictly ascending and each must be a validator of the block.
Validators who have left the set sign old or new headers Signatures anchor only from the current set's first block on, and only under the current set. Older blocks come in by parent hash from an anchored block, which trusts no signer.
Anchor a second header at a height already anchored Refused.
Upgrade the client, or change its set by hand No owner, no proxy, no setter.

4. Making a light client lie: EVM blocks on STRATO

Kashima adds nothing here and takes nothing away: a route names a light client address and trusts what it anchors. See sections 6 and 7 for what that means for the clients in this repository.

A chain the hub cannot prove (DeferredLightClient)

A route to such a chain (HyperEVM) is open outwards only. Inwards it names a light client that has anchored nothing, so nothing "from" that chain can be delivered whatever is presented. The one thing to trust is its owner's single choice, if it is ever made, of the light client to install behind it: a consumer that lists a peer on the chain is exposed to that choice as it would be to a new route opened by the endpoint's owner. Before the choice is made, or after the owner renounces, there is nothing to trust.

5. Stopping a message

Attempt What happens
Censor a delivery Delivery is permissionless. One honest party with access to both chains is enough.
Front-run a delivery The front-runner delivers the message. Nothing is gained.
Deliver with too little gas so the receiver fails The receiver's revert reverts the delivery; the message stays. A receiver that catches its own failures must check gasleft() itself (kashima.md).
Deliver while the receiver is not ready (not allowlisted yet, paused) The receiver reverts and the message waits. PortalKashimaAdapter checks the portal's allowlist before minting because the portal's hook would return, not revert.
Out-of-order delivery to confuse the receiver There is no order to violate. A consumer that needs one has the nonce.
Stuff the sending EVM transaction with logs so a log proof exceeds STRATO's per-transaction gas deliverFromState proves a storage slot instead; its cost does not depend on the transaction. Where the light client anchors no state roots this remains possible, by the sender of the transaction only.
Withhold STRATO headers from the EVM light client Anyone can submit them; the seals are the authority.
Halt the STRATO-side light client by not advancing its committee advanceCommittee must stay permissionless. It is, in EthLightClient. But someone has to call it: a client whose latestPeriod falls behind stops anchoring ("no committee for this period"), and with delivery from state that shows up late, as the first message that finds no anchored block after its send, not as the first message. Alarm on latestPeriod lagging the current sync-committee period and on the age of the newest anchor; do not wait for a delivery to fail. This has happened: on 2026-10-01 a testnet EthLightClient was found 25 periods (about a month) behind, its watcher failing every minute for want of gas in the relayer account and saying so only to a log, unnoticed because the one flow in use did not go through the light client. The account was empty because the watcher had retried one advance 212 times, a minute apart, each attempt running out of gas at the 400,000 limit and paying its fee; whether a given advance fits that limit was left to chance. A keeper must tell a failure that will repeat from one that will pass, and stop on the first kind; and a service that is up and logging its own failure is not live.
Make an anchor or a committee advance too expensive to run, by being a sync committee member who does not sign Each absent member costs the light client a decompression and an addition, and forty-odd in one transaction exceed the 400,000 budget (on helium, 38 fit and 45 do not). buildAggregate spreads that work over as many transactions as it takes and caches the result, so the anchor itself costs the same whoever signed. Below two thirds participation there is nothing to anchor, on Ethereum's rules as well as ours. No prover is needed for liveness.
Seize or pause an endpoint There is nothing to seize.

6. What is assumed

These are the trust base. Each is an assumption, not a claim.

  1. More than two thirds of the stake behind every STRATO validator set the EVM client has followed was honest while that set was in force. STRATO's own assumption. There is no slashing in the staking contract as of pbbo, so the assumption is not economically enforced.

  2. The EVM client is kept fresh. A set that has been voted out keeps its authority over a client until someone submits the header that voted it out. Submission is permissionless and a keeper should do it promptly. The unbonding delay (7 days on helium) is not a defence while nothing is slashed. A client with a trusting period, which freezes if it goes stale, would close this at the price of a way to halt the bridge; it is not built.

  3. The header the EVM client starts from is real. It is one hash, genesisHash(), checkable against the chain forever. The deploy script verifies the header's seals against its own set before deploying.

  4. No validator key set holds a quorum on two STRATO networks. A STRATO commit signs keccak256(blockHash || 0x02) and a header names no network, so a header finalized on one network verifies on a client of another if the same keys are a quorum on both. Only the node can bind a signature to a network. Whether helium and upquark share validator keys has not been checked.

  5. The STRATO-side light client of each route is sound, and stays the contract it was when the route was opened. For EthLightClient that means Ethereum's sync committee assumption (two thirds of 512 honest) for the committee of the latest period the client knows and the one before it. A committee further back is retired: it signs nothing the client accepts (_notRetired), because sync-committee signatures are never slashed and a committee that has rotated out has nothing at stake. Before the 2026-10-15 audit every committee ever installed kept its authority for good, which is the EVM V1 client's flaw (section 7) in the other direction. The client must therefore be kept advanced: an update signed by a committee two periods behind the latest is refused, and the keeper's advance comes first. Beyond that it means whoever can change the contract, and, only if an aggregate verifier is set, the PLONK verifier and its setup. The two ways of supplying the signers' aggregate key (kashima.md, "Anchoring Ethereum on STRATO") fill one cache, so a client that accepts proofs is as sound as the weaker of the two: a false proof accepted by a broken verifier installs a key of the forger's choosing for a bitfield, and an anchor signed by that key then verifies. A client with aggregateVerifier unset derives every aggregate itself and assumes nothing about PLONK; a route that wants that names such a client, with ownership renounced so that none can be set later. EthLightClient is Ownable, and upgradeable if deployed behind a proxy (the testnet ones are not): its owner can re-bootstrap it, replace the aggregate verifier (setAggregateVerifier), set a committee commitment by hand (setCommitteeCommitment) and move the proof indices, any of which lets it anchor a block that never existed. That owner is in the trust base of every route that names the client. A route should name a light client whose owner has been renounced or is a timelock the consumers can watch; advanceCommittee, the one call liveness depends on, is permissionless either way.

  6. The STRATO endpoint's owner opens only honest routes, until ownership is renounced. The owner cannot touch an existing route or a message. A dishonest route can forge messages only as coming from the chain id it was opened for, so it harms only consumers that trust that chain id. Consumers should read routeTo(chainId) before naming a peer on a chain.

  7. Ethereum finality. EVM -> STRATO messages are provable once the block is final (about 13 minutes on L1). For Base, the client in this repository accepts dispute-game claims that have not resolved (architecture.md, "Trust posture"), which rests on the proposer.

  8. keccak256 and secp256k1.

7. Known problems in what was imported

Not introduced here, not fixed here, and to be kept in mind by anything that builds on these contracts. From the pbbo branch's own audit of itself on 2026-10-01, re-read against the sources in this tree.

  • STRATOLightClient (V1), deployed on Sepolia and Base Sepolia. Counts heads, so it disagrees with the chain once stakes are unequal: it can accept a header the chain would refuse, and can be unable to anchor a block finalized by a few heavy validators. Accepts, below its tip, any header signed by two thirds of ANY set it has ever known, at any height not yet anchored: a retired set can anchor false history. UUPS-upgradeable by a single owner (a threshold-1 Safe on Sepolia, an EOA on Base Sepolia). On helium today four validators hold near-equal stake, so head count and stake agree; that is a fact about today.

  • BridgeVault and StratoNativeRepresentationBridge do not check the receipt's status. Safe only because a failed STRATO transaction's receipt holds fee logs alone.

  • The vault's cold path cannot match. It hashes WithdrawalRequested; MercataBridge emits WithdrawalRequestedV2. Over-threshold proof withdrawals can only be refunded.

  • Fixed-arity decoders against events with zero-able fields. NativeWithdrawalRequested ends in a bool that is false when the instant path is off; the first Withdrawal per chain has prevWithdrawalBlock and seq of 0. If SolidVM drops them, the decoders refuse the log.

  • PLONK. Everything but CeremonyAggregateProof uses a test SRS. The circuit must commit to both coordinates of each committee key and use complete addition; keep both if circuits/ is touched.

  • SolidVM gas. Hashing is unmetered; loops are what cost. Anything that loops over attacker-sized input (a 3000-header parent walk on Linea) is a denial-of-service surface within the 400,000 budget.

  • LambdaBridgeIn-style out-of-band refunds (Lambdachain's repo): a deposit refunded by the vault owner stays claimable, because its proof is still valid. kashima.md, "Never settle a message out of band".

  • BaseLightClient trusts unresolved dispute games. It anchors a Base block on the claim of any game that was created, and a claim is only a claim until the game resolves. Whoever can create a game (on OP-Stack chains with permissionless proofs, anyone who posts the bond) can anchor a Base state that never existed and deliver messages from it to STRATO. The off-chain check in ts that a claim matches the real block protects a relayer from anchoring a bad claim by mistake and nobody from one who means to. What it needs is a proof, against the L1 state root, that the game resolved for its claim (or that the claim is the rollup's anchor state), which costs the days a game takes to resolve. Until then a route through this client is a demonstration. With the router this reaches further: a false Base block could put a message "from Base" on every chain the hub routes to, not only on STRATO. Decided 2026-10-02: the client is hardened to resolved games before anything is routed out of Base on mainnet. BaseLightClientV2 is that client: it anchors only the block claimed by a game the rollup's AnchorStateRegistry adopted as its anchor (resolved for the proposer, past the finality delay, not blacklisted), proven from L1 state. Built and tested on real Base Sepolia data; not yet behind any route.

  • The imported LineaLightClient could not anchor a real block. It required a Linea header's state root to equal the state root finalized on L1, and those are roots of two different trees. LineaLightClientV2, written here, verifies Linea's own state proofs instead (kashima.md, "Anchoring Linea on STRATO"). The imported one, never deployed, was removed from the tree on 2026-10-15, as was the imported BscLightClient that BscLightClientV2 replaced.

  • BaseLightClient could not anchor a real block as imported. It hashed the output root with a one-byte version where the chain uses a 32-byte word, and its tests built their own L1 log with the same formula. Fixed here and tested on a real game.

  • Old state has to come from somewhere. Delivery from state through a rollup's light client proves storage at a block that is an hour old or more when it becomes provable. That needs an archive node, or a relayer that took the proof while the block was new. A public RPC that keeps a minute of state (Robinhood Chain Testnet's) makes the second the only way, and makes delivery depend on a relayer having been watching. The message is not lost if nobody was: any later confirmed block proves it too, to whoever captures a proof at that one. Linea adds a deadline of its own: a block is anchored through the newest finalized state, which holds its hash for 8191 blocks, so a Linea message nobody relays for about half a day needs an archive node to be delivered at all. A relayer that runs on a schedule is part of what makes these routes live.

8. What has not been verified

Run on testnet on 2026-10-01 (docs/testnet.md): both directions, with real proofs, between helium and Sepolia. That settled several items that were listed here: a STRATO bytes event argument is written to the receipt faithfully; bytes and bytes[] arguments reach a SolidVM contract intact through the self-custody path; deliverFromState with an 8-node Sepolia account proof, deliver with a 781-byte receipt and a five-node proof, the anchor and the aggregate-proof verification each fit helium's 400,000 gas budget; the real portals sent and received settlements through the endpoints in both directions; a light client anchored and advanced with no prover at all; and a helium message was delivered on Base Sepolia from the STRATO endpoint's storage. What remains:

  • SolidVM gas has no margin measurement. The calls above succeeded; how close they came to the budget is unknown, because nothing reports gas used. A deeper account trie (mainnet) or a larger receipt on the log path could cross it. The check is still to rebuild solid-vm-cli with the four hardcoded gas constants in Simple.hs set to 400000 and run the suites.
  • A committee advance with a proven aggregate. advanceCommittee has run on helium on an aggregate built by buildAggregate (period 1374 to 1375, no prover). The same advance on a submitAggregateProof has not been run; anchors on a proof have.
  • A committee with many absentees, on a real update. The real Sepolia updates anchored so far had nobody absent, so each build was one call. With made-up bitfields over the real committee on helium, 42 absentees built in two calls of 28, one call of 38 fit, and one call of 45 ran out of gas (402,757 against 400,000). The tests run a real update with 42 absentees in up to five calls. The worst case allowed, 170 absentees (seven calls of 28), has not been run anywhere.
  • STRATO's storage encoding beyond integers. StratoStateProof reads an integer, [0x00, n], and refuses everything else. That is all Kashima stores. The shapes were taken from a node's answers (a local chain and helium), not from a specification.
  • The state-proof route on helium's other nodes. One node serves it (buildtest, redeployed from develop by CI). Until it ships in a release, delivery from STRATO state depends on that node being up.
  • STRATOLightClientV2 against a validator leaving. It has followed real stake changes (helium 524005 and 540923) and a real validator joining (helium 691454, live on Base Sepolia and in the tests). A validator leaving is tested on synthetic headers only.
  • strato/contracts/deploy/deployKashima.js --apply (the OAuth path) has only been dry-run; the testnet deployment used the self-custody driver in strato/contracts/e2e/.
  • No audit.

SolidVM and bloc: the landmines

SolidVM is not solc and the bloc gateway is not an RPC. Everything here is a case where valid-looking code compiles, deploys, runs, and is silently wrong — each one cost someone a debugging session, and none of them raise an error.

Read this before writing a contract that lives on STRATO or a decoder that reads one.


1. A default-valued event argument is DELETED, at any position

The rule: every argument of a proven event must be non-default by construction, and every decoder must check the exact argument count and revert otherwise.

Emit an event where one argument is zero, false, the zero address, "", empty bytes or an empty array, and that argument may not appear in the receipt at all. Not as an empty item — absent. Every argument after it shifts one place left, so a positional decoder reads the next field's value into the missing field's slot and keeps going, confidently, with everything misaligned.

It is not a trailing-field problem. It was found as one — a withdrawal log whose last field was zero — and the obvious conclusion ("keep the last field non-zero") is too narrow and would have let this bite again from the middle.

Why (traced in the node, strato-platform pbbo)

toNull in strato/VM/SolidVM/solid-vm-model/src/SolidVM/Model/Value.hs maps SInteger 0, SBool False, the zero address, "", empty bytes and empty arrays to a single SNULL. The receipts trie stores a typed argument, not a string: valueToTypedArg in .../SolidVM/Model/TypedArg.hs converts each argument Value to a TypedArg and returns Nothing for anything it cannot canonicalize, SNULL included. eventToReceiptLog in strato/core/vm-tools/src/Blockchain/Bagger/Transactions.hs then builds the log's argument list as catMaybes of those conversions.

So an argument that reaches emit as SNULL is dropped, at whatever position it held.

Whether a given zero becomes SNULL or a real SInteger 0 depends on how the expression was evaluated. The observed case is a read of a never-written storage slot. The others are not mapped. That is exactly why the rule is "non-default by construction" and not "avoid storage reads" — the safe set is unknown, so do not rely on being outside it.

Cirrus and the string-form event API are unaffected, which is what makes this so quiet: the event looks right everywhere except in the receipt that a proof reads.

Two more facts from the same code

  • indexed does nothing to the receipt encoding. All arguments are one positional list. If you are used to solc's topics-versus-data split, forget it here; indexed is a Cirrus/query concern only.
  • Every receipt begins with the fee-payment logs. A log's index within the receipt is therefore not its index among the contract's own events. A failed transaction's receipt has status 0 and only those fee logs — which is why StratoLogVerifier refuses status != 1, and why a consumer that trusted "log 0 is my event" would read a fee payment instead.

Consequence for any fix

The encoding is consensus-affecting, and every event already on chain was written under the current rule. A corrected encoder and a corrected decoder will disagree with history. Anything that lands has to be fork-gated and the decoders versioned by block height; a silent change makes every existing proven log unreadable, which is worse than the bug.


2. The bloc wallet path cannot carry bytes

Transactions submitted through bloc's ?username= User-wallet path have every argument stringified and re-encoded. A bytes or bytes32 argument arrives as the text of a hex literal and is stored as that text. No error, no revert — the call succeeds and the value is garbage.

Submit anything with a bytes parameter as the signer (-direct), not through the wallet.

This is not theoretical: RollupCore.depositCollateral(bytes operation) takes an 81-byte deposit record, and it can only ever be called directly. That in turn means the signer must hold the collateral, because the contract requires the record's owner to equal msg.sender — a constraint that only exists because of the encoding hazard.

What does carry bytes (checked on helium, 2026-10-01, with a probe contract): a FUNCTION call signed by a plain key and submitted WITHOUT ?username= -- the node builds the transaction at /bloc/v2.2/transaction/unsigned, the key signs its EIP-712 hash, and it is posted to /strato-api/eth/v1.2/transaction (strato/contracts/deploy/selfCustody.js). Pass bytes as raw base16 with no 0x, and bytes[] as an array of those. A 7-byte value with a leading zero and bytes above 0x7f arrived with the right length and the right keccak256, a bytes[] of two elements likewise, and 512 48-byte keys went into EthLightClient.bootstrap the same way. Contract CREATION is the one thing that must go through ?username=, and its arguments should be addresses and integers only.

A bytes EVENT argument is also faithful: the same probe's event came back from the receipt-proof route byte for byte.

3. The bloc wallet path cannot resolve bool

The same stringification cannot turn "true" into a bool parameter. Address arguments survive (an address string parses); booleans do not.

So a contract whose admin surface must be callable through the owning wallet cannot have a boolean setter. Split it into two argument-free functions instead. CreditRouter has allow(address) and disallow(address) rather than setAllowed(address, bool) for exactly this reason, and the comment on it says so, so nobody "simplifies" it back.


4. abi.encode writes a bytes32 as dynamic bytes

abi.encode(uint256, address, uint256) is byte-identical to solc's. Put a bytes32 in the list -- a keccak256(...) result, say -- and SolidVM encodes it as dynamic bytes: an offset word in the head, then a length word and the 32 bytes in the tail. Three words where solc writes one, so anything hashed over the encoding disagrees with the EVM side, silently.

Encode a bytes32 as uint256(x) when the bytes have to match solc. The STRATO endpoint does this to recompute the message hash the EVM endpoint stored (Kashima.deliverFromState); the test that caught it proves real eth_getProof output.

abi.decode with a type list is not available at all; read words by hand. bytes has no substring and no b[i:j]; copy a range a byte at a time. a + b concatenates two bytes, and abi.encode(...words...) + payload is byte-identical to solc's bytes.concat(abi.encode(...), payload), which is how the router's envelope is laid out.

A contract's chainId is not reachable from another contract: x.chainId() on a contract-typed value is the account's own attribute, not a call to a getter of that name, and does not compile as a call. Pass the number in.


5. A resubmitted transaction is ignored, and its old verdict is what you read back

The node's pool keeps a set of transaction hashes it has seen (Bagger.addToQueued is unlessM (wasSeen t)). Submit a transaction whose hash is in that set and nothing happens: no validation, no execution, no new result. Ask for the result by hash and you get the stored one.

So a transaction that was rejected before it was mined -- its fee could not be paid, say -- cannot be retried by sending it again. Same sender, same nonce, same arguments and same gas limit sign to the same hash, and the answer comes back "insufficient funds (expected: 10000000000000000, actual: 0)" long after the account has been funded. Every number in that message is a constant in the node; it means only that the fee step failed, once.

This cost a month: a relayer whose account ran dry at nonce 1000 resubmitted the identical transaction every minute from 2026-09-23, read the same stored rejection each time, and kept reading it after it was funded.

A retry must change the bytes. Any field will do; the declared gas limit is the harmless one, since execution is capped at 400,000 whatever is declared. And because a transaction that does mine pays its fee even when it fails, a retry loop must also be bounded. Varying the bytes makes the chain answer; varying them every minute without a cap is how the account ran dry in the first place. Anything that calls Kashima.deliver on a schedule needs both.

To see what an account last did, read its record (/account?address=: nonce and latestBlockNum) or ask for /transaction?from=...&sortby=desc&limit=n. Without sortby=desc the route returns the first 1000 rows ascending, and the last row of that page looks exactly like the latest transaction.


6. bloc cannot build a call whose struct-array argument is empty

/bloc/v2.2/transaction/unsigned answers 500 ("Runtime Error ... inside of bloc") when an argument of type Struct[] is []. With one element it builds; with the argument left out it is a 400 naming the arguments it expected. Checked on helium, 2026-10-01, against EthLightClient.anchorBlockHeader, whose parentChain is empty in the commonest case (anchor the finalized header itself). SolidVM is not the problem: the same call with an empty array runs in solid-vm-cli.

Do not make "empty array" a mode of a function a key has to call. Give the empty case its own entrypoint without the parameter. EthLightClient.anchorFinalizedHeader exists for this reason.


7. An unset bytes32 read from a mapping is not reliably bytes32(0)

bytes32 known = roots[blockNumber];          // never written
require(known == bytes32(0) || known == root, "conflict");   // reverts

Seen in BaseLightClient (2026-10-02): for a key never written, the comparison with bytes32(0) was false and the require fired on the first write. EthLightClient.isAnchored carries a comment about the same thing, and tests a uint256 field instead.

Keep a bool (or a non-zero uint256) beside any bytes32 whose absence means something, and test that. A value returned across a call compares as expected (Kashima requires getStateRoot(n) != bytes32(0) of its light client and that works); it is the read-and-compare inside one contract that does not.


8. A base contract's private function is not found through a derived contract

contract Base { function f() external { _g(); }  function _g() private { ... } }
contract Derived is Base { }
Derived(d).f();      // "unknown variable: not found: _g"

In Solidity private hides a function from derived contracts' code; the base's own code can always call it. In SolidVM the call is resolved in the derived contract's scope and fails at run time. Found when a test harness inherited LineaLightClientV2 (2026-10-02).

Use internal for any helper of a contract that may be inherited, test harnesses included.


9. What gas is

SolidVM charges for evaluating the program text, not for what the machine does (solid-vm/src/Blockchain/SolidVM.hs): 1 per statement, 1 per expression node (every literal, variable read and operator), 1 more per arithmetic operator and more again when its operands are wide, 5 per function call, and fixed prices for the cryptographic builtins. Hashing with keccak256 is nearly free; a loop of plain arithmetic is not.

What follows from it, with a 400,000 budget per transaction:

  • Write hot arithmetic with the fewest operators, not the fewest lines. Poseidon2.sol is generated, unrolled, with the limbs in locals and the reductions it can skip skipped: 22 permutations fit a transaction; a loop over arrays fits a fraction of that.
  • Work that cannot fit is made resumable, each call bound to the next by what it stores, and nothing trusted in between: buildAggregate in EthLightClient, the walks in LineaLightClientV2.
  • Measure on a node. solid-vm-cli does not report gas; a probe contract with a loop count as its argument, called with larger and larger counts, does (Poseidon2Probe, in this repository's history).

9a. A local read straight from storage follows later writes to it

uint256 was = delivering;   // not a copy
delivering = 7;
return was;                 // 7, not the old value

A value-typed local initialised from a state variable or a mapping entry is a reference to it, not a copy: a later write to the storage shows through the local. Found writing KashimaV2.addRoute (the id it returned was the count after the increment) and its save-and-restore of the route being delivered; measured with solid-vm-cli, 2026-10-05. Any arithmetic makes a value: uint256 was = delivering + 0;.

9b. A state variable still at its default may reach a call as no argument

k.routeStatus(SRC, r0), with r0 a state variable never written (so 0), read a different entry than k.routeStatus(SRC, uint256(0)) or k.routeStatus(SRC, r0 + 0): the default-valued argument behaves as if it were absent, as event arguments do (section 1). Seen in a test, 2026-10-05; pass x + 0, or a computed value, where a state variable may still be at its default.

9c. block.timestamp is 0 under solid-vm-cli

A test run has no block time: block.timestamp == 0. A "0 means unset" timestamp (activeAt != 0 && activeAt <= block.timestamp) is therefore never true in a test with no delay. Found writing KashimaRouterV3.isPeer, 2026-10-06. Keep a bool beside a time whose absence means something (section 7 says the same of bytes32).


10. Rules that follow

For any event a proof will read:

  1. Non-default by construction. Every argument. If a field can legitimately be zero, carry it biased (value + 1), or carry a sentinel, or split the event — do not emit the zero.
  2. Check the exact argument count and revert. Not >=. A decoder that is merely wrong about arity is indistinguishable from one reading a different event, and will happily produce a plausible, wrong answer.
  3. Version the event. Never add, remove or reorder an argument of a proven event in place. The count is load-bearing by rule 2, so a change is a new event, not an edit.
  4. Do not assume your event is log 0. Fee logs come first.
  5. Refuse status != 1. A failed transaction still produces a receipt.