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
sendfrom 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.
- Require the caller to be the endpoint.
- 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.
- 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.
PortalKashimaAdapterdoes 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. ItsonKashimaMessagedecodes the deposit and callscreditTrustlessDeposit(...); the target trusts its caller completely, so the adapter is where the peer check lives. Outbound, the bridge (or the adapter on its behalf) callssendwith 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
sendfrom 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 byPortalKashimaAdapter.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
srcChainIdis the chain the message was first sent on,senderis the contract that called the router'ssendthere,nonceis the number the origin endpoint gave the first leg, which is also what the router'ssendreturned to the sender,payloadis 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:
- 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. endpoint.send(next chain, router there, first-leg envelope)withsender = msg.sender. Return the endpoint's nonce.
onKashimaMessage, called by the endpoint:
- The caller is the endpoint, and
(linkSrc, linkSender)is the router registered forlinkSrc. Otherwise revert. - 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. - If
dstChainIdis this chain: mark(originChainId, originNonce)delivered, reverting if it already is, and callreceiver.onKashimaMessage(originChainId, originSender, originNonce, payload). - 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
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.Topology. One hub. See the table above for what reaches it.
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
MessageSentit 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".
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.
Thin transport. Unordered, exactly-once, no fragments, no retries. Size: a payload is at most
maxPayloadbytes, checked insendon 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.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, asopenRouteon 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:
- 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.
- An application can decline. One that must never transit points at the endpoint and is unaffected by any of this.
- 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:
- 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.
- The Base light client is hardened before anything is routed out of
Base on mainnet.
BaseLightClientmust stop taking the claim of an unresolved game. Not done yet. - 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
- 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.
- Hardening
BaseLightClient(decision 2). - A testnet deployment and a run through the hub between two EVM chains.
- HyperEVM: what a light client of its consensus would take.
- 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.senderof itssend). 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.
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.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.
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.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.The STRATO-side light client of each route is sound, and stays the contract it was when the route was opened. For
EthLightClientthat 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 withaggregateVerifierunset 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.EthLightClientisOwnable, 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.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.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.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.BridgeVaultandStratoNativeRepresentationBridgedo 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;MercataBridgeemitsWithdrawalRequestedV2. Over-threshold proof withdrawals can only be refunded.Fixed-arity decoders against events with zero-able fields.
NativeWithdrawalRequestedends in a bool that is false when the instant path is off; the firstWithdrawalper chain hasprevWithdrawalBlockandseqof 0. If SolidVM drops them, the decoders refuse the log.PLONK. Everything but
CeremonyAggregateProofuses a test SRS. The circuit must commit to both coordinates of each committee key and use complete addition; keep both ifcircuits/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".BaseLightClienttrusts 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 intsthat 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.BaseLightClientV2is 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
LineaLightClientcould 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 importedBscLightClientthatBscLightClientV2replaced.BaseLightClientcould 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-cliwith the four hardcoded gas constants inSimple.hsset to 400000 and run the suites. - A committee advance with a proven aggregate.
advanceCommitteehas run on helium on an aggregate built bybuildAggregate(period 1374 to 1375, no prover). The same advance on asubmitAggregateProofhas 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.
StratoStateProofreads 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. STRATOLightClientV2against 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 instrato/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
indexeddoes 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;indexedis 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 0and only those fee logs — which is whyStratoLogVerifierrefusesstatus != 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.solis 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:
buildAggregateinEthLightClient, the walks inLineaLightClientV2. - Measure on a node.
solid-vm-clidoes 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:
- 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. - 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. - 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.
- Do not assume your event is log 0. Fee logs come first.
- Refuse
status != 1. A failed transaction still produces a receipt.