@rootzero/contracts
v1.52.0
Published
Solidity contracts and protocol building blocks for rootzero hosts and commands.
Readme
rootzero
rootzero is a protocol for building hosts: contracts that expose a uniform set of endpoints over accounts and assets — commands that change state, queries that read it, and port links that connect hosts to each other, on the same chain or across chains.
This repository is @rootzero/contracts, the Solidity library for the EVM port
of the protocol: the base contracts, block codecs, and helpers that rootzero
applications compose.
Two decisions shape everything below. First, all data that crosses a host boundary is encoded in one binary block format, so an input means the same bytes on every chain. Second, every surface operates on runs of blocks rather than single values, so batching is the default, not a feature added later. This guide introduces the protocol bottom-up: blocks, then identities, then hosts and the endpoints built on top of them.
Quick Start
Scaffold a ready-to-run Hardhat project, or add the library to an existing one:
npx create-rootzero@latest my-app
# or
npm install @rootzero/contractsA minimal commander-only host composes CommandHost with the endpoints it
needs and implements their policy hooks:
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.33;
import { Balances, CommandHost } from "@rootzero/contracts/Core.sol";
import { Deposit } from "@rootzero/contracts/Endpoints.sol";
contract ExampleHost is CommandHost, Balances, Deposit {
constructor(uint commander) CommandHost(commander) {}
function deposit(bytes32 account, bytes32 asset, uint amount) internal override returns (uint) {
creditTo(account, asset, amount);
return amount;
}
}CommandHost requires a nonzero local commander host ID and accepts command
calls only from its embedded native caller. It has no built-in admin commands, peer registry, guardians,
inbound introduction endpoint, generic execution command, or native-token
receive function. Use Host instead when the application needs those advanced
facilities; its commands accept the commander, the host itself, and explicitly
authorized host callers.
Both host types introduce themselves during deployment when the native target
encoded by the commander host ID is a contract. That commander must implement
introduce(uint,uint) and accept the call, otherwise deployment reverts. Host
IDs encoding EOAs do not receive an introduction call.
Host contracts are designed for fresh deployment rather than proxy upgrades. Releases may change inheritance storage layout, immutable configuration, and encoded identity formats; storage compatibility across versions is not supported.
Pipelines and trusted outbound port callers require CommandAccess and
PortAccess implementations respectively. The advanced Host supplies both
directly through its node policy, while CommandHost deliberately does not. A
minimal host can implement narrow custom policies. Both access capabilities
return the authorized endpoint's selector and address for direct use with the
free raw-call helpers.
Deploy it with the local host ID encoding your native identity as commander and you can call its commands
directly. An input is a run of binary blocks — here, a single #assetAmount block
asking to deposit an asset (the encoders are a few lines each; see
test/helpers/setup.ts and
test/helpers/blocks.ts for reference
implementations):
const commander = await hostId(deployer.address);
const host = await ethers.deployContract("ExampleHost", [commander]);
const account = encodeUserAccount(user.address); // receiving account
const input = encodeAssetAmountBlock(asset, 100n); // what to deposit
const context = encodeContextBlock(account, "0x", input);
await host.deposit(context); // logs the credited amount in the Deposit OUTPUT laneThe rest of this guide explains the ideas this example leans on — blocks, IDs, hosts, commands — and the surfaces built on top of them.
Blocks
Every input, response, and piece of in-flight state is a stream of typed blocks. A block is a four-byte key, a four-byte big-endian length, and a payload:
[bytes4 key][uint32 payloadLen][payload]The key is usually bytes4(keccak256("#name")), and the payload layout is
described by a schema body published under an alias. For example, the standard
assetAmount block that requests a deposit:
assetAmount: { bytes32 asset, uint amount }Use #assetAmount when the caller supplies both the asset and quantity.
The scalar #amount { uint amount } block is available when the consumer
already defines the asset or unit. It has a distinct 32-byte payload.
is 72 bytes on the wire: an 8-byte header followed by two big-endian 32-byte fields. There is no ABI encoding and no chain-specific type anywhere in the format — field types are chain-neutral integers, bytes, and booleans. A deposit input built for an EVM host is byte-for-byte the input a CosmWasm or Solana port would parse; what differs per chain is how a host resolves the identifiers inside, never how the bytes are laid out.
Schemas can express more than flat fields: a block may contain any number of
nested child blocks (#bytes as payload names raw dynamic bytes), items can be
marked many when they form a repeated list. Each declared child header is
present, and its payload must satisfy that block's schema. Bytes, strings, and
lists may have zero-length payloads; there is no universal empty-block marker.
An at N hint can reposition one field in off-chain presentation without changing its wire
position. Qualified schema names such as relay.input describe encoded block
streams inside aliased #bytes fields, preserving ordinary block headers and
decoder helpers; schemas emitted locally by the active host take precedence
over trusted-context and standard schemas with the same name.
Schema strings may start with an optional name: prefix. For example,
assets: many #asset as assets names the schema assets and independently
names its list item assets; helpers take the complete string without a
separate name argument.
Standard block aliases are intrinsic protocol metadata: indexers resolve names
such as balance, step, and context from their standard keys even when no
named schema annotation is emitted.
Entities such as commands and assets may carry a #counterparty { bytes32 account }
annotation identifying their counterparty account, including host accounts. The latest trusted value
replaces the earlier association, and account zero identifies Rootzero. The
helper emits the claim without validating either ID or granting authority.
The full schema language is specified in
docs/Schema.md. The standard block schemas live in
Schemas and their runtime keys in Keys (both via
@rootzero/contracts/Codec.sol).
A rare top-level list is published as a custom schema consisting of one item,
such as many #asset or { many #asset }. Its context-local schema key becomes
the outer block key accepted by the endpoint; a list alongside sibling items
continues to use the generic #list key.
Batches
An input is not a single struct; it is a run of blocks. One #assetAmount block
asks for one deposit, five blocks ask for five, and the code path is identical
— every endpoint parses with a cursor and loops until the stream is exhausted.
The published lane key is the prime item: it is the block type that may repeat
for batching. Each top-level block represents one operation; fixed compositions
use a custom parent block.
Off-chain, building a batch is concatenation. Using the reference encoders from
test/helpers/blocks.ts:
import { concat } from "ethers";
import { encodeAssetAmountBlock } from "./helpers/blocks";
const input = concat([
encodeAssetAmountBlock(usdc, 250_000_000n),
encodeAssetAmountBlock(dai, 250n * 10n ** 18n),
]);
// deposit(input) returns two #balance blocks and zero native budget creditEverything downstream keeps this shape: commands loop over input blocks, posting ports loop over transactions, and pipelines loop over steps. Batching is never a special case.
IDs, Accounts, Assets, and Nodes
Everything the protocol touches - accounts, assets, chains, hosts, endpoints - is identified by one 32-byte word. The first byte selects the convention:
0x00: null/unset ID.0x01: Rootzero-native structured ID.0x02: opaque ID, encoded as0x02 || category || subtype || bytes29(hash). The hash preimage is not recoverable from the ID; use a lookup table or witness data when the native account, asset metadata, or node target is needed.0x03: EVM structured ID. The value can be deconstructed according to its EVM layout.
Opaque preimages start with:
[uint8 formatHash][uint8 category][uint8 subtype][payload...]0x01 means keccak256. The category and subtype are copied into the ID and
included in the hash, binding its visible type to the opaque identity. The
remaining payload format is host/domain-specific until a future standard
defines it.
Opaque and structured IDs share the same category and subtype taxonomy. This allows generic validation of an opaque account, asset, or node without exposing or resolving its native identity. The Solidity helpers below construct and deconstruct IDs.
Structured EVM IDs use:
[uint32 type][uint32 chainid][192-bit payload]Embedded EVM addresses always occupy the low 160 bits:
Account / ERC-20: [type:4][chain:4][zero:4] [address:20]
Node: [type:4][chain:4][selector:4][address:20]Account and ERC-20 encoders leave the middle four bytes zero. After validating
the ID's representation and category, extract the address with
address(uint160(uint256(id))).
where type packs
[uint8 representation][uint8 category][uint8 subtype][uint8 flags]. A
structured ID announces what it is (an account, an asset, a node) and which
chain it lives on, and the payload usually embeds the underlying address. User
accounts are chain-agnostic, while admin accounts are chain-local. Guardians
are normal user accounts assigned a host-specific role.
Assets are unique IDs in the same single-word form as accounts and nodes.
Nodes are hosts, commands, ports, queries, and guards.
Asset subtype 0x00 identifies the default asset for representations that
define one. Derived = 0x01 and Virtual = 0x02 remain reserved taxonomy
values without dedicated helpers or a standardized payload. Erc20 = 0x03
identifies an ERC-20 asset.
The Rootzero asset is the singleton global ID
[Rootzero][Asset][0][0] with zero chain and payload fields. Its exact value is
0x0103000000000000000000000000000000000000000000000000000000000000
on every chain. Access it as Assets.Rootzero; EVM chain-coin
and ERC-20 asset IDs remain chain-local.
Opaque asset declarations use LOG0 with Codes.AssetAnnotate followed by an
#assetPreimage { bytes32 asset, #bytes as preimage } block. Indexers read the asset
ID from the block and retain the emitter as its publisher. The preimage uses
[0x01][Asset][subtype][payload...], letting offchain indexers or witnesses
verify and resolve
[0x02][Asset][subtype][bytes29(keccak256(preimage))] assets.
The Utils.sol entry point provides the constructors and inspectors:
bytes32 account = Accounts.toUser(msg.sender); // chain-agnostic user account
bytes32 asset = Assets.toErc20(tokenAddress); // ERC-20 asset ID
uint hostId = Nodes.toHost(address(this)); // host node ID
bytes32 hostAccount = Accounts.toHost(hostId); // deterministic host account
bytes32 opaque = Ids.toKeccak(preimage); // preimage: 0x01 || category || subtype || payloadHost accounts use [Evm][Account][Host][0], a chain ID, and an address in
bits [159:0]. Layout.Host is shared with host nodes; the category distinguishes
them. Accounts.toHost(address) uses the current chain, while Accounts.toHost(uint)
validates a host node prefix and preserves its chain and address, including remote
chains. Accounts.isHost classifies the prefix; Accounts.host additionally
requires a nonzero address. Neither grants authority or requires deployed code.
A host account can be used with settle; the same host account can also identify a position handled by realize.
Account validation convention
Account identifiers remain bytes32 and are trusted internally. Validate accounts
where untrusted user input enters the system, according to the caller's account
policy, then pass them through internal execution without repeating account-format
checks. Accounts constructed by canonical internal helpers from trusted inputs
need no redundant validation. Encoding an untrusted address does not establish
that it satisfies policy; validate such inputs at the boundary.
Commands accepting user-supplied account identifiers must validate them before using them, returning them in positions, or forwarding them to another host. A trusted command must still validate untrusted input: authorizing the command does not validate its inputs. Trusted peers are responsible for the validity of accounts they supply; receiving ports need not repeat account-format validation. A faulty trusted integration can supply malformed accounts, and the receiving host does not guarantee rejection under this convention.
Internal accounting hooks, including debitAccount, creditAccount, and book,
may assume their account arguments satisfy the caller's account policy. Account
format is separate from authorization, balance checks, and operation-specific
requirements, such as a valid native payout address. Those requirements still
apply. Zero amounts, zero counterparties, and absent sides retain their documented
semantics; this convention adds no checks to skipped legs.
This is the intended integration convention, not a claim that all existing
implementation checks have been removed or that every input boundary already
enforces it. Validation helpers retain their documented checks; for example,
Accounts.addr and sendChainAsset still check the EVM family and nonzero address,
and sendChainAsset does so even for a zero amount. Existing host-specific checks
also remain. No Account or ValidatedAccount wrapper type is required.
Hosts
A host is one contract assembled from mixins. The base Host brings access
control and the admin surface (authorize, unauthorize, appoint, dismiss,
annotate, executePayable) plus the guardian revoke action; you add the
endpoints you need and the policy hooks they require. Keeping a ledger is
optional: the Balances mixin provides an account-and-asset ledger; hosts store their
own balances under Accounts.toHost(host), but a host can just as well
implement commands that hold no persistent state in the host at all —
forwarding funds elsewhere, or operating only on the state threaded through a
pipeline.
Access capabilities used by commands, ports, pipelines, and guards are abstract
hooks. The two host bases provide the concrete policies: CommandHost accepts
commands only from its commander, while Host directly implements commander,
admin, node, peer, port, command, and guardian access. Guardian state and
enforcement are part of Host; there is no separate guardian policy contract.
Hosts that implement the allowance hook can additionally inherit the opt-in
RevokeAllowance guard, which accepts hostAsset { uint host, bytes32 asset }
entries and always applies a zero allowance.
Trust is explicit and minimal. Each host receives an immutable commander host ID at construction. The EVM port validates that it is local, extracts its native address internally for caller checks, and derives the admin account from that native identity. Other contracts become callers only when their node ID is authorized into the host's trusted set, and guardians are accounts allowed to take protective actions. At deployment, a host introduces itself to its commander, which is how host topology becomes discoverable.
The ExampleHost in the quick start shows the resulting split, and it runs
through the whole library: mixins implement the protocol mechanics (parsing,
batching, discovery events), and small virtual hooks let the host decide
policy — where funds come from, how the ledger is keyed, what gets emitted.
Commands
Commands are the write endpoints. Every command receives the same context:
struct CommandContext {
bytes32 account; // acting account
bytes state; // block stream produced by the previous command
bytes input; // block stream for this invocation
}Every command returns a state block stream and a trusted native credit.
State is threaded into the next pipeline step, while credit replenishes the
shared pipeline budget without validation against forwarded call value.
Command trust is the authority boundary.
State is linear, not optional ambient context. A command is responsible for
the entire state stream it receives: it must validate and consume it, transform
and return it, forward it intact, or revert. A command must never succeed while
silently ignoring or dropping supplied state. Published endpoint schemas remain
discovery metadata; the command's decoding and loop implementation defines its
runtime source semantics. A command that does not consume supplied state rejects
it when closing, while takeState and takeStateFixed validate and consume a complete
state stream before forwarding it. takeBalances validates every forwarded block as BALANCE and
returns a cursor for relayBalancePayable to encode directly; empty state remains accepted.
This is especially important for #position, because dropping it could
silently discard an outstanding debt requirement.
The input carries instructions; the state carries live value. While a sequence
of commands executes, #balance, #custody, and #position blocks in
the state are the value being moved — produced by one command, consumed by the
next. Balance carries { asset, amount },
custody carries { host, asset, amount }, and position carries the flat
position { asset, amount, liability, debt, counterparty }. The counterparty
field identifies the settlement counterparty. Zero identifies Rootzero and
is handled by exact booking through Settlement.settle. Generic codecs preserve the field;
settle passes all five fields as a Position memory struct to its hook for
fulfillment under the host's authorization policy. Account format follows the
boundary-validation convention. Settlement hooks take
(account, position), plus Execution memory funds for funded settlement.
Settlement accepts empty input and consumes any number of POSITION blocks.
Producers enforce minimum asset amounts and maximum debts before emitting their
final positions, using packed LIMITS where appropriate. Producers handle fees
before supplying the position: amount is the final net receipt and debt is the final total payment.
Settlement receives the final position, books zero-counterparty positions on the active
account, and passes nonzero counterparties to the account hooks when exchanging the
exact debt and asset quantities. It adds no fees and makes no separate host
fee credits. Account hooks and custom BookHook implementations may trust account
format while preserving applicable authorization and balance checks. Empty
exchanges skip the account hooks and do not validate the counterparty.
Repay settles only the debt: POSITION → POSITION with empty input. Its
RepayHook.repay(account, position) must satisfy the entire debt or revert,
without mutating the position. The command then sets debt = 0 and preserves
all other fields. Settlement.repay uses BookHook to debit the account and,
for a nonzero counterparty, credit that counterparty with the exact payment.
Zero debt skips booking. A following Settle consumes the remaining asset leg.
Every nonzero position counterparty is an account, including host accounts.
settle can exchange balances with any account counterparty on the executing
host. A host account can instead be realized by its host, whose hook compares
against Accounts.toHost(host) and fulfills the obligation before returning zero.
The subtype identifies the account, not the route: offchain metadata and available
balances determine where to settle or realize. See counterparty semantics.
Hosts that maintain account balances implement settle; hosts without their own
balance ledger implement realize. A production host chooses one model rather
than exposing both for the same operation. Settlement supplies reusable ledger
mechanics with a default settle implementation that applies final quantities exactly.
Either side of a position may be absent. An absent asset side is encoded as
asset = 0, amount = 0; an absent liability side is encoded as
liability = 0, debt = 0. This mirrors transaction blocks, where a zero from
or to omits that side of the transfer. A one-sided position remains useful
when a command must preserve position-shaped state for later composition;
a liability-only position is the standard representation of debt. An asset-only
value can also use the narrower #balance block.
#position is general live state rather than a persisted
lending-specific debt record. Its liability side carries value owed or required; it
pairs that liability with value acquired or controlled. A command may preserve
or replace either side and return the resulting state for the next step;
settle terminally consumes the position, exchanging with an account
counterparty or applying a zero-counterparty booking. An optional checkPosition
step validates limits before settlement. This supports swaps,
borrowing, refinancing, collateral changes, callback obligations, cross-host
claims, fees, netting, and other multi-step operations. Positions are
transient representations and do not themselves create or erase an obligation
recorded by an external system.
At settlement, the debt side is the final total payment and the asset side is the final net receipt, with producer fees already accounted for. Limits protect these quantities; they do not cover separate charges outside the position.
The quantity in a debt side is an exact obligation. A command that consumes
debt = 100 must deliver, make available, or otherwise satisfy all 100, or
revert. Transfer fees and other sourcing costs are paid in addition to the debt
or reflected in the gross amount sourced upstream; they must not silently
reduce the fulfilled quantity. When fulfillment produces custody, the amount
that actually reaches custody must equal the debt. Partial fulfillment requires
preserving the unsatisfied remainder in a position rather than consuming it.
SwapExactIn and SwapExactOut in commands/Swap.sol share the input schema
bytes32 asset, uint amount, many #asset as hops and return one POSITION per
SWAP input. For swapExactIn, asset and amount identify the input liability
and exact debt; hops run forward toward the output asset. For swapExactOut,
asset and amount identify the desired output; hops run in reverse toward
the input asset. Both routes exclude the asset named in the fixed fields.
For an A ? B ? C swap, exact-in encodes A with hops [B, C], while exact-out
encodes C with hops [B, A].
Commands validate each ASSET block. Empty routes call no hook and produce a
Position with equal asset/liability and amount/debt; the position is still returned
and logged, and normal settlement rules apply. The exact-in
hook takes (liability, debt, asset) and returns the amount received; the
exact-out hook takes (asset, amount, liability) and returns the debt required.
The command feeds each result into the next hop and builds one aggregate Position
using the shared immutable Counterparty. A concrete host initializes that base
with Counterparty(account) once, even when it inherits both swap commands.
Hooks validate amounts and asset pairs and settle intermediate assets internally.
Use position-constraint commands to check the resulting amounts separately.
The standard Deposit mixin shows the canonical shape: open the execution,
decode its input, call the hook, and write the output run. Execution helpers
route known state blocks (#balance, #custody, and #position) to
state automatically; generic and custom-schema decoding consumes input:
function deposit(bytes calldata context) external onlyCommand returns (bytes memory, uint) {
return runCommand(id, descriptor, context, depositOne);
}
function depositOne(Execution memory exec) private {
(bytes32 asset, uint amount) = exec.unpackAssetAmount();
amount = deposit(exec.account, asset, amount); // host policy hook
exec.outputBalance(asset, amount);
}A command announces itself when the host is deployed. Its constructor emits a discovery event carrying the packed state, input, and output lanes (spec plus codes), plus a human-readable label. Flags are carried by the endpoint ID; the helper separately returns an execution descriptor for efficient opening:
abstract contract MyCommand is CommandBase {
uint private immutable id;
uint private immutable descriptor;
constructor() {
(id, descriptor) = command("myCommand", Specs.Empty, Specs.AssetAmount, Specs.Balance, 0);
}
function myCommand(bytes calldata context) external onlyCommand returns (bytes memory, uint) {
return runCommand(id, descriptor, context, myCommandOne);
}
function myCommandOne(Execution memory exec) private pure {
(bytes32 asset, uint amount) = exec.unpackAssetAmount();
exec.outputBalance(asset, amount);
}
}Callback runners in CommandBase, AdminBase, PortBase, GuardBase, and QueryBase provide the standard lifecycle:
runCommand(id, descriptor, context, callback) processes command batches,
runCommandOnce(id, descriptor, context, callback) invokes its callback exactly once,
runAdmin(id, descriptor, context, callback) authorizes the admin context before
processing a batch, including when its sources are empty,
runPort(id, descriptor, input, callback) processes port batches and returns output plus
remaining value credit, and runQuery(descriptor, input, callback) processes queries
through an internal view callback and returns only response bytes. Each callback
receives the shared Execution memory. Batch callbacks must consume an item on
every invocation; runCommandOnce also invokes its callback for empty sources and rejects
leftover data afterward. Entry-point access modifiers remain in place.
runCommand, runCommandOnce, and runAdmin use the registered id to prefix topic-free logs selected by
nonzero lane codes (Lanes.create(spec, codes)). Before processing,
exec.logContext(id, descriptor) emits selected STATE/INPUT containers together.
Selected output is emitted as an OUTPUT container after processing; returned bytes
remain the original stream. See
command runner stream logs.
runPort logs selected INPUT before processing and OUTPUT afterward.
runGuard(id, descriptor, input, callback) logs selected INPUT and processes a
guard batch without output or a budget. Queries remain view-only and reject
nonzero lane codes during registration.
For custom loops, call logContext or logInput immediately after opening.
finish(id, descriptor) finalizes output and logs it when selected, without
checking consumption or touching the budget. Use drainBudget() to return and
clear credit. Use expectEnd() to require exact consumption, or
close(id, descriptor) to combine that check, finalization, logging, and credit.
The no-argument finish() and close() variants remain pure and silent.
Do not append output after finalization.
Use <endpoint>One for per-item private callbacks, such as depositOne and
portCreditAccountOne, and <endpoint>Once for whole-input callbacks passed to
runCommandOnce, such as relayPayableOnce. Endpoint-specific names allow composing
endpoint mixins: Solidity rejects
conflicting private callback signatures in multiple base contracts. Keep explicit
opening and closing for custom lifecycle work, such as pipe settlement after the
entire batch.
Deposit hooks return the actual amount that becomes live #balance state.
Implementations can therefore deduct external ingress fees or report an
otherwise adjusted received amount without overstating the value passed to the
next pipeline step. Account debits are exact internal bookkeeping operations:
they must make the complete requested amount available or revert. Any account
fee is charged in addition and does not reduce the resulting balance.
Commands can describe grouped lanes with GroupsAnnot, available through
Core.sol and Commands.sol:
annotateGroups(id, "#state as (debit, credit), #output as (receipt, change)");Only grouped lanes are listed. Their schemas come from the published endpoint specs; empty lanes remain empty. Counts and roles are off-chain hints, with no descriptor fields or runtime enforcement. An empty description clears previous hints.
Commands can publish execution estimates with ExecutionCost, available
through Core.sol and Commands.sol:
executionCost(id, 10_000, 5_000); // base cost per invocation, additional cost per batchThis emits #executionCost { uint base, uint batch }. Estimated command cost is
base + batch * batchCount, in destination-local execution units. A batch is one
logical group processed by the command; grouped inputs count as one batch, not
one per constituent block. The concrete host supplies estimates for its hooks.
Pipeline and transport overhead, plus any safety margin, are added separately
by the planner or destination adapter. These are advisory estimates, not enforced
limits or guaranteed bounds. Unknown batch counts or missing annotations mean
unknown cost. The latest trusted annotation replaces the previous estimate;
zero values are valid estimates and do not clear metadata.
Execution opening now allocates its growable output buffer from the descriptor
hint. openInput and openContext accept optional numerator/denominator
arguments to scale that hint before allocation; Executions.scaleOutput remains removed.
Output helpers contain no deferred initialization or scaling checks; buffers
still grow when needed.
The final argument is a packed flags byte. Pass 0 for an ordinary endpoint,
or compose values such as Flags.Funded, Flags.Admin, and
Flags.AdminFunded from the command or endpoint package entry point.
The same flags byte is copied into the endpoint ID, keeping runtime behavior and
published endpoint metadata aligned. Flags.Handoff marks a command that
takes ownership of the remaining pipeline, while Flags.HandoffFunded combines
handoff behavior with native-value funding;
bits 2 through 5 are unassigned, and bit 6 remains endpoint-defined.
Lane codes select logging independently of this flags byte.
CashoutHook declares an abstract cashout(account, amount) hook. Hosts implement
their payout policy and choose the accounting and events to emit. The hook has
no ChainAsset inheritance or built-in flow logging.
The free sendChainAsset(account, amount) helper in core/Cash.sol, also exported
by Core.sol and Endpoints.sol, transfers the exact amount to
Accounts.addr(account), accepting EVM-backed account subtypes with a nonzero
address. Failed transfers revert with the global SendFailed() error without
copying recipient return data. Zero amounts still validate the account and call
the recipient; hook implementations may skip zero amounts before calling.
The helper performs no bookkeeping and emits no events. The host must authorize
and fund the withdrawal, finalize accounting before calling it, and protect its
entrypoints against reentrancy.
The standard commands cover the common ledger movements: bootstrap (source
an initial balance and native-value budget), cashout (withdraw native
#balance state), deposit and
depositPayable (external funds in), settlePayable (funded settlement),
withdraw and burn (funds out),
debitAccount and creditAccount (internal movements), payout (deliver
state to other accounts), realize (pass each position to
realize(account, position); the hook fulfills it in the existing denominations and
returns counterparty zero; input is empty, and an optional following
checkPosition validates the result against POSITION_CONSTRAINTS),
checkBalance (validate each balance's asset and amount against paired BALANCE_CONSTRAINTS
and return it unchanged; ExecuteCheckBalance validates memory state directly),
checkPosition (validate each position against paired POSITION_CONSTRAINTS and return it unchanged;
ExecuteCheckPosition validates memory state directly), settle (consume
asset-liability position state with empty input, including
Rootzero-backed and liability-only positions),
relayPayable (relay a pipeline without
state), and relayBalancePayable (relay balance state and a pipeline to another
portal).
Pipelines
A single command is rarely the whole story. A pipeline is a run of #step
blocks executed in order within one transaction:
step { uint cmd, uint value, #input }Each step names a command, the native value it may spend, and its input.
The returned state threads into the next command and the final state must be
empty. Each returned native credit replenishes the budget before running the
next step, allowing one command to fund later commands. The standard
bootstrap command consumes exactly one
#bootstrap { uint budget, many #assetAmount as balances } block and returns one
#balance per requested item, in order. Non-chain assets debit the account as
requested. Assigned step value funds chainAsset balances first; their uncovered
amounts and any shortfall against the minimum remaining budget debit chainAsset
once after the loop. Excess assigned value remains available as returned credit.
An empty balances list supports budget-only funding. Bootstrap logs actual nonzero
debits in one Account/Bootstrap Balance stream after funding, with the combined
chainAsset debit last. No debit means no log.
Bootstrap is registered with command metadata but is only executable through
local pipeline execution. This is the core of
Pipeline.pipe:
uint cursor;
assembly ("memory-safe") {
cursor := or(steps.offset, shl(32, add(steps.offset, steps.length)))
}
while (uint32(cursor) < uint32(cursor >> 32)) {
uint cmd;
uint value;
(cmd, value, cursor) = takeStep(cursor);
if (value > budget) revert InsufficientValue();
unchecked { budget -= value; }
(state, value, cursor) = run(cmd, account, state, value, cursor);
budget += value;
}
if (state.length != 0) revert UnexpectedState();Pipeline.pipe takes the available native-value budget as a uint and returns
the remaining budget after every step has executed. The enclosing entrypoint
settles that final value once.
portPipePayable shares one native-value budget across all supplied CONTEXT
blocks. After all contexts execute, it calls cashin once for any nonzero
remainder when the last context's account is nonzero, and returns zero native credit.
Context ordering determines the recipient. Empty input or a zero final account
skips cashin and returns the remainder as budget credit, without transferring
native value back. The trusted peer is responsible for supplied account validity.
An exhausted or zero budget skips the hook.
The EVM pipeline is deliberately coupled to the canonical wire layout for gas
efficiency. It extracts command selectors, targets, and flags directly from
command IDs and writes CONTEXT, STATE, INPUT, BYTES, and RELAY blocks directly in assembly.
Any change to those ID fields or block encodings must update Pipeline at the
same time; the general node and block helpers are not used on this hot path.
A handoff command retains the ordinary command subtype and carries
Flags.Handoff in its endpoint ID. The reserved handoff envelope is:
relay { #input, #bytes as steps }input is the handoff STEP's ordinary command input, while steps is the
untouched remainder of the original pipeline. Pipeline.pipe constructs this
envelope automatically for commands carrying Flags.Handoff, calls the command,
and stops executing the transferred continuation locally. Pipeline authors
therefore encode only the command's ordinary input in the handoff STEP. Relay
implementations can publish a qualified relay.input schema to describe how
offchain tooling should decode the ordinary keyed blocks inside that byte
payload. Implementations can use Blocks.exact for exactly one block, or open
the standard decoder and close it after consuming a batch. The standard relay
commands pass the account and this input to their transport hook alongside a
fully constructed canonical #context containing the account, forwarded state,
and remaining steps, plus the handoff command's funds. The transport can
therefore use the account directly and forward the context without
reconstructing its protocol payload.
Handoff has four operational rules:
- A host-local
executehook must returnhandled = falsefor handoff command IDs. The hook sees only ordinary STEP input; delegation letsPipelineconstruct the RELAY envelope containing the continuation. - Handoff transfers STEP bytes, not the pipeline's complete native-value budget. The handoff command receives its assigned STEP value. Returned credit rejoins the source budget, and the enclosing source entrypoint settles the final remainder normally.
- The handoff command must consume or forward the current state and return empty
state. Because the continuation is no longer executed locally, returned
non-empty state fails pipeline finalization with
UnexpectedState. - A transport that completes asynchronously should relinquish only state that is safe before destination success is known. The standard relay commands are intended for empty or balance state; debt, position, and similarly fallible state must not be relayed this way.
A transfer, for instance, is a two-step pipeline: debitAccount turns an
#assetAmount input into #balance state, and payout consumes that state
toward a recipient. Because a pipeline is just blocks, it is also the unit of
command batching. STEP, CALL, and RECOVER carry full-width native uint value
drawn from the shared budget. CALL funds a local call; RECOVER funds its handler
invocation. DISPATCH carries opaque, packed chain-specific resources for adapters
that also need gas or runtime parameters. A resources word is never itself
native value; EVM adapters use
useResourceValue to extract its low 128-bit value lane before spending it.
Local execute adapters use Execute for fixed-stride decoding: validate
stream size once, then decode at absolute positions. Memory unpackers have a
Memory suffix; calldata input stays a cursor until bounds are extracted.
The paired balance/position checks validate streams in place, and settlement
copies each position into an independent struct. See Execute.
Hosts that implement a pipeline locally can inherit ExecuteBootstrap,
ExecuteCashout, ExecuteDebitAccount, ExecuteCreditAccount, and
ExecuteSettle to register canonical command metadata while executing
their local command IDs through executeBootstrap, executeCashout,
executeDebitAccount, executeCreditAccount, and executeSettle. The bootstrap and debit adapters decode fixed-stride AssetAmount calldata
input directly; cashout, credit, and settle decode memory-backed pipeline
state. All return handled = true and avoid an
external self-call. The host's execute hook must authorize a command before
returning true. Returning handled = false delegates a local command to its
trusted normal external entrypoint. Pass the step value into each adapter.
Bootstrap is pipeline-local rather than an
externally callable command and may consume value for chain-asset balance;
the other four return assigned value unused as pipeline credit. Their external
command entrypoints remain nonpayable.
ExecuteAuthorize extends Authorize with an executeAuthorize adapter for
NODE input and empty state, returning assigned value unused as pipeline credit. It checks
enforceAdmin(account, address(this)) internally; the default Host policy
therefore requires a self-managed host. Pipeline entrypoints must authenticate
the account and prevent peer-supplied admin accounts. Dispatching authorizeId()
directly to this adapter keeps local authorization available independently of
the node allowlist. The external authorize endpoint retains its caller checks.
Positions also support backward-composed pipelines. In an exact-output route, the asset side can represent the desired result while the liability side represents the value currently required upstream. Each hop consumes one position, fulfills or transforms its current requirement, and returns the next position:
position(C, 100, C, 100)
→ position(C, 100, B, 50)
→ position(C, 100, A, 25)
→ settle()This is backward composition, not backward execution: #step blocks still
execute forward in their encoded order. Exact-output routing is only one use;
other commands may transform the asset side, the liability side, or both.
Queries
Queries are the read endpoints: view functions that take a block-stream input
and return a block-stream response, with the same batch shape as commands. The
standard getBalance query takes a run of account-asset positions
and answers each one in order. Query the host's own holdings by supplying its
deterministic host account:
input: accountAsset { bytes32 account, bytes32 asset }
response: accountBalance { bytes32 account, bytes32 asset, uint amount }Like commands, every query announces its input and output specs at deployment; tooling resolves their keys through the published block schemas.
GetEntityCodes in queries/Entity.sol exposes entityCodes, which accepts
#entity { uint entity } blocks
and returns one #codes { uint codes } block per entity, preserving input order
and duplicates. Empty input returns empty output. Entity identifiers use the
same full-width representation as annotations; the hook defines supported kinds.
Codes describe entity kinds and current conditions, not historical actions or effects. Zero codes
means unknown or no condition reported; States.Inactive means explicitly inactive.
Active/Inactive is optional for entities where it does not apply. The hook owns
condition semantics and code packing; the query preserves its returned word.
GetAssetCodes exposes the asset-specific assetCodes query. Its hook must return
exactly one Active or Inactive state. Both query mixins and their matching
GetAssetCodesHook and GetEntityCodesHook contracts are exported by Endpoints.sol.
Ports
Settlement and the book port share BookHook from core/Settlement.sol:
book(Booking memory value). Settlement forwards it to its virtual scalar
book(from, to, liability, debt, asset, amount) overload, which implements it by
debiting debt from from before crediting amount to to, skipping zero
amounts. Matching accounts or assets are not netted. Hosts may implement the
hook directly while preserving those exact-leg and funding requirements.
Settlement also calls it for both exact exchange transfers, liability first.
Calls.raw (from Core.sol) calls ports returning (bytes output, uint credit),
with overloads for bytes memory data and validated calldata uint dataCur.
They take (selector, target, value, data, expectEmpty), strictly decode the
tuple, and preserve target failures in FailedCall. expectEmpty constrains
the output bytes only. Callers authorize the target and must ensure returned
credit is backed before adding it to their budget; the helpers do not transfer
ETH back. All ports return this tuple. Nonpayable ports return zero credit;
portDispatchPayable returns its unspent budget. portPipePayable settles its
remainder through cashin and returns zero credit when the final account is
nonzero; otherwise it returns the unspent budget.
Calls.rawQuery decodes bytes-only query results. Calls.tryRaw has the same
memory/cursor overloads and reports success without decoding returndata, with
optional explicit gas limits. Cursor overloads trust validated bounds, ignore
metadata, and do not advance the cursor. All Calls functions are internal helpers.
exec.rawCall(selector, target, value, data, expectEmpty) provides the same
memory/cursor overloads with execution budget accounting.
They debit the full-width native uint value before calling, add the returned
trusted credit to exec.budget, and return only the output bytes. Recovery hooks
pass their value directly. Dispatch adapters interpreting packed resources
extract the EVM value lane before passing it to a local call.
Target authorization and credit backing remain the caller's responsibility.
Ports are the host-to-host surfaces, callable only by trusted peer hosts. A trusted peer is a fully trusted extension of the receiving host. Admitting a peer authorizes it to use every port the host exposes. Peer trust is not a limited permission to perform selected operations.
Before admission, the host's maintainers must fully validate the peer's behavior against the host's invariants, including its externally reachable entrypoints, dependencies, and upgrade authority. The peer must prevent its callers from causing unauthorized or invalid actions on the receiving host. For example, a peer that credits backing must establish that backing before calling the credit port. The receiving host relies on that guarantee instead of proving it again for each operation. Changes to the peer or its dependencies require renewed validation of the trust decision.
The port authenticates the peer once at entry through onlyPeer. There is no
additional peer-specific authorization by port, asset, direction, or individual
operation inside the batch. This keeps repeated authorization out of the
execution path. Parsing, balance sufficiency, arithmetic checks, and other
operation invariants still apply; they establish validity, not different
permissions for different trusted peers. A component that cannot be trusted
with the full port surface must not be admitted as a trusted peer.
The central ports are batches all the way down:
portRequestAssetconsumesassetAmount { bytes32 asset, uint amount }blocks and passes the authenticated peer, asset, and amount to a host hook. The hook validates asset support and applies the host's request and transfer policy.portRequestAllowanceconsumes the same assetAmount blocks and lets the authenticated peer set its own asset allowance through the same authoritative hook used by the admin allowance command.portBookconsumes BOOKING blocks with fieldsfrom, to, liability, debt, asset, amount. Each 200-byte block describes one debit fromfromand one credit toto. Its input lane usesSpecs.Bookingwithout operation logging. Account hooks own updated-balance logging. Both accounts may be the same. It returns empty bytes and zero credit, and any malformed block or account-hook failure reverts the entire batch and any hook logs. The complete struct is decoded beforebook(value); zero quantities skip their legs. For transfers, use the same asset and amount on both sides. For a single-sided entry, explicitly set the omitted leg to zero debt or amount; a zero account alone does not omit a leg. TheTxstruct and transaction helpers remain available, but this port consumes a singlebookingblock per operation.portCreditAccountandportDebitAccountconsumeaccountAmountblocks and apply the operation to the specified account. To credit or debit a host, passAccounts.toHost(host)as that account. The same account hooks handle both. Hook implementers emitLogs.accountBalance(account, asset, updatedBalance)after mutations; these ports do not also log the requested amounts.portPipePayableconsumescontextblocks, each carrying an account, an initial state, and a run of steps — a complete pipeline delivered by another host, executed locally against the port call's shared value budget.
This is also the cross-portal mechanism. relayPayable and
relayBalancePayable are handoff commands whose transport hooks decode their
own destination and resource input and forward an already constructed command
context, while portDispatchPayable dispatches an explicit portal payload. A
Portal forwards ordinary incoming CONTEXT streams directly to its commander
host's portPipePayable endpoint. The pipeline port settles any remainder to
the last context's account when nonzero and returns zero credit. Empty input or
a zero final account returns the unspent budget instead. The portal ignores all
successful return data and does not decode the message or perform account settlement.
Failed messages are retained by digest and may be replayed through a separately
selected trusted recovery-handler port. A
bridge adapter moves the raw
bytes; the destination host parses them with the same cursor rules and runs
the same pipeline loop. Nothing in the payload is EVM-specific — step commands
are destination-local command IDs, and only the adapter boundary (native
transfers, address resolution, signatures) is chain-specific. The parity rule
for ports is strict: every chain's implementation must parse the same input
bytes and produce the same output bytes for every endpoint.
Guards and Admin
Admin commands use the regular command shape but are gated to the host's admin
account: trust management (authorize, unauthorize), guardian management
(appoint, dismiss), metadata (annotate), optional asset gating
(allowAsset, denyAsset, allowance), and raw calls (executePayable).
AddPool and RemovePool in commands/admin/Pool.sol provide optional pool
administration. addPool consumes consecutive pairs of ASSET_AMOUNT blocks and calls
its hook with two AssetAmount values; removePool consumes ASSET pairs and
passes their identifiers. Both publish #input as (first, second) grouping,
accept empty batches, and return empty output. Incomplete pairs or hook failures
revert the entire batch. Hosts define pair ordering, asset and amount validation,
funding, and removal requirements. These commands identify pools by their asset
pair; hosts with multiple pools per pair need a more specific identifier.
Guards go the other way: direct actions guardians can take
without any command context — the default is revoke, which lets a guardian
drop a trusted node immediately.
Events and Discovery
Hosts publish Endpoint and Annotation blocks for endpoint lanes, schemas and human-readable labels. Protocol logs use LOG0 with scope/action/state codes or endpoint IDs followed by block streams. Indexers preload the standard catalog and apply each endpoint's documented semantics to balances and other state. Code catalogs are exported by Utils.sol; Logs and the block codecs by Codec.sol. Legacy event mixins, EventEmitter, EventAbi and the Events.sol barrel are removed. Applications can still declare their own Solidity events.
Development
The default build uses Solidity 0.8.35 with viaIR: true, optimizer enabled
at 200 runs, and the Cancun EVM target. Tests and benchmarks use those same
settings. Evaluate gas optimizations under this configuration; older benchmark
documents explicitly measured without viaIR are historical comparisons.
Applications importing these internal libraries should use matching compiler
settings when reproducing the measurements.
For repository development, npm test runs regular tests without the
*.bench.test.ts suites. Run a focused test with
npm test -- test/peer.test.ts, or filter the regular suite with
npm test -- --grep "Port Entrypoints".
Run npm run bench for benchmarks, or
npm run bench -- test/settlement.bench.test.ts for a specific benchmark.
Use benchmarks for performance changes, baseline updates, and release checks.
npm run test:all runs the complete suite. npm run typecheck checks TypeScript.
Use npm test -- --list or npm run bench -- --list to inspect suite selection.
Using the Library
Import from the package entry points rather than deep paths:
@rootzero/contracts/Core.sol—Host, annotation helpers includingCounterpartyAnnot, access control,Balances,Settlement,ExecuteHook,PipeHook,ForwardHook,Pipeline,Portal, validator@rootzero/contracts/Commands.sol—CommandBase,Execution,Flags, annotation and codec helpers, and shared value types for authoring custom commands@rootzero/contracts/Endpoints.sol— command, admin, port, guard, and query mixins, their hooks (includingExecuteHookandPipeHook), andFlags@rootzero/contracts/Codec.sol—Blocks,Encoder,Execute,Cursors,Schemas,Execution/Executions,Flags,Keys, andSpecs@rootzero/contracts/Utils.sol—Ids,Nodes,Assets,Accounts, cursor, layout, and value helpers
Core.sol, Commands.sol, Endpoints.sol, and Utils.sol each export all
shared protocol errors from utils/Errors.sol, including QueryFailed and
SendFailed. Core-specific errors such as AccessDenied and FailedCall
are exported by Core.sol.
Repo layout:
contracts/core— host, access control, balances, pipeline, validationcontracts/commands— standard commands and admin commandscontracts/ports— port surfaces for inter-host and cross-portal flowscontracts/guards— guardian direct actionscontracts/queries— read-only query endpointscontracts/codec— block schemas, cursor parsing, buffers, and writerscontracts/utils— ids, nodes, assets, accounts, layout, ECDSAdocs—Schema.md(wire format and schema DSL)
Use this library to create a new rootzero host, implement a command, or reuse the protocol's block format in tooling. It is the shared protocol foundation, not an end-user application.
Optional output logs
Set output lane codes with Lanes.create(spec, codes) to have the endpoint runner
emit one [endpoint ID][OUTPUT block] log after processing the batch. Output helpers
only append blocks; returned streams remain unchanged. Both swap commands publish
Actions.Swap in their output lane and use this format, including empty batches.
Logs carry no implicit account. See runner logs
for indexer rules. Application-defined ordinary events use their own supplied ABIs.
