Status: the EVM side is built and tested; everything off the EVM is design. Covers how a call array is serialized for the wire and what the commitment is computed over, on EVM and elsewhere.
They get conflated, and keeping them apart is what lets each VM do the sensible thing:
Wire encoding: how the payload travels. The receiver decodes it.
Commitment preimage: what gets hashed. The receiver re-derives it from the decoded fields and compares.
They are independent. The wire format can change without changing any commitment, and both are owned by the receiver on the destination chain. Nothing above them (not the transceiver, not the transmitter, not the bridge) inspects either one.
This is the layer that stays VM-agnostic, and it is what the code already does:
function hashCalls(bytes32 destinationChainKey, bytes[] memory elements)
internal pure returns (bytes32)
{
bytes32 hashed = keccak256(abi.encode(destinationChainKey));
for (uint256 i; i < elements.length; ++i) {
hashed = keccak256(abi.encodePacked(hashed, keccak256(elements[i])));
}
return hashed;
}An element is bytes. The hash never looks inside one. The destination chainKey is folded in first, so a payload approved for one chain cannot be finalized on another, and, usefully here, so the element format is namespaced per destination for free.
The home's contracts therefore never need to understand a non-EVM call. Every commitment path hashes opaque bytes; Solana and Move payload construction happens entirely in off-chain tooling. There is no Borsh or BCS anywhere in Solidity, ever.
struct Call {
address target;
uint256 value;
bytes data;
}
// payload = abi.encode(Call[] calls)This is the ERC-7579 Execution tuple, also used by ERC-7821. The reason to prefer it over
bytes[] is not the bytes saved: it is that payload builders which already speak modular-
account formats work without custom code.
Layout for one call carrying 36 bytes of data:
32 offset to array
32 array length 1
32 offset to element 0
── element 0 ──
32 target
32 value
32 offset to data
32 data length 36
64 data padded to 2 words
─── 288
Per element:
// Calls.hash, in messaging/Call.sol
function hash(Call memory c) internal pure returns (bytes32) {
return keccak256(abi.encode(c.target, c.value, c.data));
}Hash the fields, not the struct. abi.encode(someStruct) prepends an offset word,
because a struct with a dynamic member encodes as a dynamic tuple. keccak256(abi.encode(c))
and keccak256(abi.encode(c.target, c.value, c.data)) are different values. The second is
the one that matches the opaque form.
That equivalence is the point: Calls.hash(c) equals keccak256(element) whenever the
element is abi.encode(target, value, data), and Calls.encode(c) produces exactly that
element. So a typed overload and the canonical opaque form produce identical commitments,
and the transmitter can offer both: typed calls for EVM destinations, where signers get a
readable payload, and opaque bytes for everywhere else.
hashCalls(Call[] calls) // this chain's key, for a receiver, view
hashCalls(bytes32 chainKey, bytes[] elements) // canonical, keccak256, pure
hashCalls(bytes32 chainKey, Call[] calls) // same hash, typed, pure
hashCalls(Scheme, bytes32 chainKey, bytes[]) // per-destination, view
hashCalls(Scheme, bytes32 chainKey, Call[]) // per-destination, viewOne name, five shapes. Every array parameter is memory: Solidity will not overload on
data location, so a calldata twin would need a different name, which is the only reason
a second one ever existed.
The three unparameterized forms are the EVM scheme (keccak256). The first seeds with
ChainKey.local() and is what ReceiverBase.finalize recomputes, which is why it is
view rather than pure: it reads block.chainid. The Scheme overloads are for the
source side, where the home builds a commitment a different VM will recompute. See below.
This is asserted directly, not assumed: test/PayloadEncoding.t.sol includes a fuzz case
over (target, value, data, chainKey). If the two ever diverge, a payload approved in one
form silently stops matching in the other, and it fails only on a live message.
There is no discriminant on the wire. An EVM destination always receives Call[]; every
other VM always receives opaque bytes[].
EVM destination wire = abi.encode(Call[] calls)
everything else wire = abi.encode(bytes[] elements)
The sender picks by destination chain type. On path B the transmitter picks on-chain:
bootstrap(payload, attributes) takes Payload.encodeBootstrap(destinationChainIdentifier, wire), which is abi.encode(bytes, bytes), and decodes wire in the form the identifier's
chain type implies before the transceiver builds its envelope, so what crosses is always that
form. A body built for the other form reverts in that decode, at the quote as at the send. On
path A it is not. sendMessage takes the payload
already built, and bytes cannot be asked which form it holds, so the pairing is the
caller's to get right. payloadForCalls and payloadForElements exist so it is at least
spelled the same way here as it is decoded there. The receiver still decodes the single
shape its own VM implies. Both sides know which before a byte is written, so a field saying
so would carry a value each already holds: every conduit channel carries exactly one
shape. Envelope is the exception, and is tagged by kind, because a transceiver channel
carries bootstraps and reports and does not stay one-directional.
This is a structural guarantee, not a decoder guarantee, and the distinction is worth
holding onto. abi.decode of the wrong shape does revert for these two layouts (asserted
in test_theTwoEncodingsDoNotDecodeAsEachOther), but that is a property of how they
collide, not a promise the ABI decoder makes. What actually prevents a misread is that no
path exists which sends opaque elements to an EVM receiver. If one is ever added (a
destination that accepts both), the tag has to come back, because at that point the
destination's VM stops determining shape. Payload.sol carries that
tripwire in a comment.
ReceiverBase exposes finalize(Call[]), finalize(Call[][]), and execute(Call[]), and
no opaque twin. An EVM receiver executes EVM calls; there is no payload it can run that is
not (target, value, data), so an opaque overload would accept elements it could only
decode into this shape anyway or revert on. One entry shape means one place the caller gate
lives and one place the policy check lives.
The commitment it discharges may still have been built in either form: that layer is
VM-agnostic and off-chain tooling naturally produces the canonical opaque elements. The
equivalence is what makes this work: an approval computed over bytes[] is satisfied by
the typed array supplied here.
The same applies on the source side. TransmitterBase.execute is Call[] only, because it
is a direct local call with no bridge in between and therefore always targets an EVM chain.
A transmitter has no commit of its own, because it holds no approvals. What it has is
commitmentCall(receiver, commitment), a pure builder for the one element that pins a
hash on the receiver's own chain, and cancellationCall(receiver, commitment) for the
element that withdraws one. Both name the hash, so neither can go stale. Committing is a call, not a message kind, so a payload for a
destination this chain cannot execute on is approved by carrying that element rather than by
a second entry point here.
Zero calls would hash to keccak256(abi.encode(chainKey)): non-zero, and so an approval
of nothing. Every fold refuses an empty array (Commitment.EmptyCommitment, and
SchemeFold's copy), so one can be neither previewed nor finalized, and execute refuses
one too. A bootstrap may still carry none, since standing the receiver up is the intent.
A delivered payload of zero calls also runs, as a no-op: refusing it would leave the message
stuck and retryable at the provider, while the no-op costs only the fee its owner paid.
There is no portable Call. The blocker is not address width: it is that the call shape
differs structurally per VM:
| VM | Call shape | Native serialization |
|---|---|---|
| EVM | (target, value, data) |
ABI |
| Starknet | (to, selector, calldata: felt252[]) |
felt array |
| Solana | (program_id, accounts: AccountMeta[], data) |
Borsh |
| Aptos | (module, function, type_args[], args[]) |
BCS |
| Sui | commands + inputs, referencing objects and prior results | BCS |
Starknet carries the selector as its own field rather than as the first four bytes of calldata. Solana requires every account an instruction touches to be enumerated with signer/writable flags: information that does not exist in an EVM call and cannot be derived from one. Sui arguments can reference the output of an earlier command in the same block.
A universal struct would compile everywhere and be a lie on four of those five, with a
value field that means nothing outside the EVM: every other VM moves native currency as
an explicit asset or resource.
So each VM's receiver owns both its wire format and its commitment preimage, in its own
native serialization. The container above them is uniform; the elements are not, and
neither, necessarily, is the hash. See Scheme below.
It masks the output to 250 bits so the result fits in a felt, and it is what Cairo uses for entrypoint selectors. A Starknet receiver that computes the commitment with it will never match the home. Full keccak256 is available but is the less obvious import.
Two questions that get answered together and should not be: can the destination verify a commitment, and can it execute what the commitment approved. The first is portable. The second is not, and on two of the target VMs the answer is no for reasons that cannot be engineered around at the encoding layer.
The fold never parses an element, so a destination needs only keccak256 over byte strings it never interprets:
seed = keccak256(chainKey) // abi.encode(bytes32) is just the 32 bytes
acc = keccak256(acc ‖ keccak256(element_i)) // encodePacked of two bytes32 is 64 bytes
No ABI decoder, no Borsh, no BCS. Both steps are byte concatenation and a hash. This is the property the whole VM-agnostic commitment layer buys, and it holds on every chain listed here.
Two things it depends on that are easy to miss:
keccak256 is not universal, so the scheme is a parameter. Where it is a native primitive, it stays the default; where it is not, the destination hashes with something else and the source has to build the commitment the same way.
| ChainType | keccak256 there | Scheme |
|---|---|---|
EIP155 |
opcode | Keccak256 |
SOLANA |
sol_keccak256 syscall |
Keccak256 |
APTOS |
aptos_hash::keccak256 |
Keccak256 |
SUI |
sui::hash::keccak256 |
Keccak256 |
NEAR |
env::keccak256 host fn |
Keccak256 |
COSMOS |
no host function: compiled into the wasm | Keccak256, priced by payload |
STARKNET |
library code, not a builtin; starknet_keccak is a different hash |
Poseidon |
BIP122 |
no opcode exists | n/a: no executor |
Out of scope, but each has an enum arm in Commitment.Scheme already: Cardano would use
Blake2b256Scheme (its keccak_256 builtin arrived later than its other hashes, so the
Plutus version needs checking), and TON would use Sha256: TVM has SHA256 natively and
no keccak primitive. Neither has a ChainType allocated in addressing/ChainType.sol
today.
So Starknet is the only in-scope chain where keccak is the deciding obstacle. CosmWasm's is a cost difference rather than a capability one: the hash is wasm rather than a host call, so it is priced by payload size instead of being nearly free.
enum Scheme { Keccak256, Sha256, Blake2b256Scheme, Poseidon }This is a deliberate narrowing and it gives something up. A fully VM-native commitment
would be TON's cell hash (sha256 over a cell tree, because a TVM cell holds only 1023
bits and any real payload is therefore a tree), or Starknet's poseidon_hash_span over a
felt array. Neither is a byte-oriented fold, and neither could be reproduced at home to
show a signer what they are approving. Holding the fold fixed keeps both sides able to
compute one value; the cost is that a non-EVM receiver implements a byte fold rather than
its idiomatic digest.
The home can still compute most of them, which matters more than it first appears
because a preview is read through eth_call when a signer checks a payload, so gas is
not charged in the path that matters.
| Scheme | At home | Mutability |
|---|---|---|
Keccak256 |
opcode | pure |
Sha256 |
builtin (precompile 0x02) | pure |
Blake2b256Scheme |
Blake2b256.sol, EIP-152 precompile 0x09 |
view |
Poseidon |
not implemented | reverts |
Blake2b is what forces every dispatching function to view rather than pure: Solidity
forbids staticcall inside pure. IVmDeriver already pays this exact tax for the same
precompile, for the same reason.
Poseidon is declared and not implemented. Poseidon over the Starknet field needs the
exact round constants and MDS matrix, and one wrong constant produces a silently wrong
digest, so it is not written from memory. It reverts with SchemeNotComputable rather
than falling back to keccak, because a silent fallback would hand back a well-formed
commitment that a Starknet receiver can never match, failing only on a live message.
Commitment.isComputable(scheme) is the ask-before-you-build read, and it is false only for
Poseidon. Until it is ported and checked against Starknet vectors (not built yet;
todo.md §4), a Starknet
commitment is computed off-chain and carried in an opaque element that calls that receiver's
own commit, which is the same mechanism every deferred payload uses.
Where the scheme is a parameter, and where it is not. The enum is how Commitment
dispatches internally and is compiled into every conduit, but no transmitter entry point
takes one.
commitmentFor/commitmentForChain name an EVM destination, and every chain that executes
Call[] hashes with keccak256, so both stay keccak-only and pure: exactly one primitive
they can ever need, which is what makes it safe for them to be frozen with the conduit. The
overload that took a Scheme is gone, because it was frozen in the worst possible place:
the set of primitives it could name was fixed at the conduit's creation, so a chain onboarded
later would be unpreviewable on every conduit already live. Non-EVM destinations are
previewed through ChainRegistry.commitmentFor, where the primitive is an
ICommitmentScheme bound per chainKey and the set grows with one owner transaction.
The plugin supplies the primitive, never the fold. ICommitmentScheme.hash(bytes) is
one function wide; the registry seeds with the chainKey and folds per element itself. So "the
fold is fixed, the primitive varies" is structural rather than conventional, and a wrong or
hostile plugin can only produce a digest the destination refuses, never a differently-shaped
one it accepts. Combined with advisory at home, enforced on the destination (a
receiver enforces with the keccak fold frozen into ReceiverBase and can never consult a
lookup), that is the whole safety argument for letting the preview be swappable.
Getting the primitive wrong therefore fails closed, the same way building a commitment with
the local chainKey does. It is not free: an unmatched commitment sits outstanding until a
cancel crosses, which is why no plugin is to be configured for a chain before it is checked
against that chain's vectors (not built yet; todo.md §4). It no longer blocks anything else, since approvals are unordered.
The chainKey is a compile-time constant almost everywhere. On EVM, ChainKey.local()
derives it from block.chainid and nothing has to be configured, which is exactly why the
commitment binds to a chain for free there. Off the EVM that does not hold:
| VM | Can it derive its own chainKey? |
|---|---|
| EVM | yes, from block.chainid |
| Starknet | yes, from get_tx_info().chain_id |
| Aptos | partially: chain_id::get() is a u8 |
| Solana | no: a program cannot tell which cluster it is on |
| Sui | no |
So on the chains where the receiver address is least predictable, the chainKey also has to
be written in at initialization. A mainnet/devnet mixup then produces a receiver that verifies nothing
successfully and fails only on a live message. The mitigation is the one the transceiver already
uses for the mirror-image value: the governor's home is a write-once initializer argument
given as a chain identifier and checked against the key it hashes to (OutboundBase._requireNames
reverts RouteKeyMismatch unless it is the canonical identifier), so the two cannot name
different chains. A baked-in chainKey off the EVM wants the same
treatment: assert it against a derived value in that chain's own test suite.
| VM | Verify | Execute an arbitrary approved array |
|---|---|---|
| EVM | yes | yes: a full-power conduit |
| Solana | yes | yes, via CPI, with the account list committed |
| Starknet | yes, awkwardly | yes: multicall is already idiomatic |
| Aptos / Sui | yes | no: Move has no general dynamic dispatch |
| Cardano | version-dependent | no: validators are not executors |
Solana: the account list has to be inside the committed element. Every account an
instruction touches must be enumerated in the transaction with signer/writable flags, and a
program can only CPI into accounts already in scope. The executor therefore supplies that
list, which means the approval has to cover it. If the accounts sit outside the
commitment, a relayer can substitute a destination token account and redirect funds at a
perfectly matching hash. That is a privilege escalation, not a formatting detail, and it is
the direct analogue of why target and value live inside the committed element on the
EVM. The element is (program_id, AccountMeta[], data), and the receiver checks the passed
accounts against the committed ones before invoking.
Two Solana limits also shape the flow rather than just the encoding: the 1232-byte transaction limit means a large payload has to be buffered into an account before it can be executed, and a batch of CPIs has to fit the compute budget. The commit/finalize split maps onto the first unusually well: pin the hash, write the payload across several transactions, then execute against it.
Starknet: executable, and the obstacles are all encoding. Array<Call> with
(to, selector, calldata) is how Starknet accounts already do multicall, so running an
approved batch is idiomatic. What stands in the way is keccak, felt252 not holding a
32-byte word, and the unsettled bytes↔felt packing convention below.
Move: a receiver can verify perfectly and still be unable to act. Move has no general
dynamic dispatch: a module cannot call an arbitrary address::module::function chosen at
runtime. Entry functions are dispatched by the transaction, not by other modules. Sui's
programmable transaction blocks come closest, but a PTB is composed off-chain by the sender
and a module cannot introspect the PTB it is part of.
So on Aptos and Sui the receiver implements a fixed vocabulary of operations it was compiled to understand, and the "call array" becomes a command array over that vocabulary. (Aptos has been adding function values for narrow cases; check the current framework version before assuming, but general dispatch is not among them.)
Cardano is not a decoder problem. eUTxO validators approve or reject spending; they do not execute. "Run this call array" has no referent. That needs a different receiver model entirely, and pretending otherwise by picking a container format for it would be solving the wrong layer.
Three claims argued in the contracts are EVM properties presented as protocol properties, and the Move case is where each of them breaks.
"Conduits are full-power and answer to one owner, as its own account would" (messaging/Executor.sol).
That is the justification for isAllowed defaulting to true and for treating the call
policy as a self-imposed restriction rather than a defence. On a Move
chain the receiver is vocabulary-limited whether anyone wants it to be or not, so the
premise is false there, and the merkle-policy work is moot, because the vocabulary is
the policy. Non-EVM receivers are constrained conduits by construction.
One argument-free Proxy per conduit, at a CREATE2 address derived from
(owner, salt, home) (conduit/Proxy.sol, TransceiverCore._conduitSalt). Move modules
are published at addresses, not instantiated; there is no per-owner deployment at all. A
Move deployment is one module holding a table keyed by owner. The one-conduit-per-owner
model, and the single address that goes with it, does not survive the trip.
Approvals live in each conduit's own receiver, so an unrelayed payload stalls nothing
(inbound/ReceiverBase.sol). That rests on one receiver per transmitter. Under a shared Move
module every owner's approvals sit in one module, so isolation becomes bookkeeping again, and
whatever a Move receiver does about it has to be argued separately rather than inherited.
Finally, Call.value is the concrete case of the general rule already stated above: every
other VM moves native currency as an explicit asset or resource, so value is an operation
in the array, not a field on an element. Solana moves SOL through a system-program
instruction; Move passes coins as resources. This is why the per-VM element format is not
optional.
- A receiver model for Move, given the vocabulary constraint and the absence of clones. This is a design question, not an encoding one, and it is the largest unresolved piece of non-EVM support.
- A receiver model for Cardano, or a decision that Cardano is out of scope.
- Whether the Solana element carries the account list, which the argument above says it must: worth stating as settled once someone writes the first vector.
The home side and each destination are written in different languages with non-overlapping toolchains, so they cannot import each other. The interface between them is a file.
Not built yet (todo.md §4). The planned layout:
test/vectors/
evm.json
starknet.json ← when there is a Starknet receiver
solana.json
Each vector holds the logical calls, the destination chainKey, the encoded payload, and the expected commitment hash. Every implementation reads the same file and neither depends on the other.
The VM that executes owns its format and generates its own vectors. The decoder is the spec, not the encoder: off-chain tooling that builds payloads is checked against the destination's vectors, not the other way round.
| Checked by | Needs | |
|---|---|---|
| Commitment hash, any VM | Foundry | keccak over bytes it never parses |
| EVM wire format and decode | Foundry | nothing beyond Foundry |
| Non-EVM decode, field by field | that VM's own suite | that toolchain |
| Encoder/decoder round-trip | Foundry --ffi |
target toolchain installed |
| End to end | devnet lane per VM | deployed contracts |
The first row is the useful one: Foundry can verify the commitment half for every destination without any non-EVM tooling, because hashing does not require parsing. The EVM side holds the line on the commitment while understanding nothing about a Solana instruction.
Matching hashes prove byte agreement, not semantic agreement. A mismatched encoder is fail-closed: the commitment does not match and nothing runs. A mismatched decoder is not: if the home's tooling encodes "transfer 100 to A" and the destination reads "transfer 1000 to B" out of those same bytes, the hash matches perfectly and the wrong thing executes.
So vectors must assert the decoded fields individually, not that a blob round-trips. This matters more on the execute-on-arrival path, where there is no commitment at all and the decoder is the only thing between a delivered message and execution.
Payload.encodeElements(bytes[]) wraps the portable form in abi.encode, which is an
EVM-ism imposed on exactly the chains least able to pay for it. That contradicts the
principle stated at the top of this file (the container is supposed to be uniform, not
EVM-shaped), and it should be decided before the first non-EVM receiver is written.
Nothing decodes ABI for free off the EVM. Per VM, roughly in order of pain:
| VM | Feasibility | The actual obstacle |
|---|---|---|
| Move (Aptos/Sui) | easy | native u256, good vector<u8>; only the big-endian swap |
| Solana | workable | compute budget and 32KB heap; must reject non-zero high words in lengths |
| Starknet | hard | felt252 is ~2^251, so a 32-byte ABI word does not fit in one |
| Cardano | wrong model | eUTxO validators are not executors; needs a different receiver design |
This table is about decoding the container, and it does not line up with the execution table above: Move decodes ABI easily and still cannot execute an arbitrary array. The two are independent problems and picking a container format solves neither of the hard ones.
A length-prefixed container decodes with a cursor in any language, has no 32-byte-word problem, and is far smaller:
count(u32 BE) | [ len(u32 BE) | element bytes ]*
One element carrying 36 bytes: 44 bytes packed against 192 ABI-framed. Most of ABI's overhead is zero-padding: cheap in EVM calldata gas, but several providers price by raw byte.
Deferring this costs nothing, which is why it is deferred rather than guessed at. The
commitment folds keccak256(element) one at a time and never sees the array framing. The
container can therefore differ per destination without changing a preimage, invalidating a
commitment, or touching anything on the approval path. ABI framing stays for now because
EVM→EVM is the only path that exists and it is the natively cheap choice there.
Blocking for Starknet specifically: the bytes↔felt packing convention. Bridges deliver
Starknet payloads as Array<felt252>, not bytes, so before any container format can be
parsed there has to be an agreed rule for packing a byte string into felts. That is
unspecified today, is needed in either container format, and is the kind of value that is
wrong once and wrong forever.
Also unverified: keccak256 availability on Cardano. Solana, Aptos, and Sui all expose
it natively; Plutus gained a keccak_256 builtin later than the others and the target
version needs checking. Starknet's starknet_keccak is not keccak256: see above.
bytes[] as the EVM wire format. It costs an extra length word per call. It needs N+1
decode passes against one for Call[]: the outer array, then each element again, copying
every data field twice. It has no standard tooling. And it shows a signer opaque blobs
where Call[] decodes to named fields in a signing UI. It remains the canonical
commitment form, since that layer must stay VM-agnostic, and the only form a non-EVM
destination receives.
A form tag on the wire. It would let an EVM caller send either form, but the form is a property of the destination and both sides know it in advance, so the byte would carry a value neither side needs told. See above for the condition that would make one necessary.
Packed, Safe MultiSend style: to(20) | value(32) | len(2) | data. Smallest by a
wide margin, and reads straight from calldata with no memory copy. Rejected for now: it
needs hand-written cursor parsing, has no standard tooling, and fixes the address at 20 bytes.
Revisit if bridge fees show up in cost modelling.
Wire bytes for comparison:
| Payload | bytes[] |
Call[] |
packed |
|---|---|---|---|
| commit (36B data) | 320 | 288 | 90 |
| ERC-20 transfer (68B data) | 352 | 320 | 122 |
| transfer + approve | 640 | 576 | 244 |