Network setup
Two things have to match for two nodes to agree on one ledger: they must be able to reach each other, and they must list the same validator set.
Generated baseline versus public overlay
matrixd -init creates a secure starting point, not a turnkey production profile. Public operators must still set identical genesis and economics, exact HTTPS CORS origins (never a wildcard), public reads, signed writes, positive 600/120 request limits, a real 64-hex maintainer account, and the verified Base bridge target. Base Sepolia rehearsal is chain 84532; Base production is 8453. See the configuration page for the overlay.
1. Start the first node
# node A
matrixd -init -config a.yaml
# edit a.yaml:
# network.listen_addr: /ip4/0.0.0.0/tcp/9000
matrixd -config a.yamlTwo lines of its output matter. The node prints its consensus identity, which is the id you put in every node's validator list:
Genesis applied: 0 named allocation(s), reward pool 1000000000000000000 native base units.
Consensus identity: 689cf718481d3c13cf4e370526d5228d758e0c2c9b6f5eb3bbc0540c20eede31
...That identity is generated on first start and persisted in the store at storage.path. Keep that directory and the node keeps its place in the set; delete it and the node becomes a different validator.
2. Point the second node at the first
# node B, on another machine (or another port on this one)
matrixd -init -config b.yaml
# edit b.yaml:
# network.listen_addr: /ip4/0.0.0.0/tcp/9001
# network.bootstrap_peers:
# - /ip4/<A's address>/tcp/9000/p2p/<A's peer id>
matrixd -config b.yamlA bootstrap entry is a full libp2p multiaddr including the peer id, which is the /p2p/... suffix. A failed dial is logged and does not stop the node, so a typo here looks like silence rather than an error - check the log.
The peer id is stable across restarts. It is a second identity, separate from the consensus one above, and it is persisted under p2p/identity/peer_key in the same store. That is worth knowing because it used to be generated fresh on every start: every multiaddr you had pasted into another node's config went stale the moment this one restarted, and libp2p verifies the id it dialed, so the only symptom was all dials failed - indistinguishable from the typo above. Keeping storage.path keeps both identities; deleting it makes the node a different validator AND a different peer.
Bootstrap peers are also re-dialled, not dialled once. A node checks every 10 seconds whether each configured peer is still connected and re-dials the ones that are not, so restarting one node does not leave the others permanently disconnected from it. The node prints the exact multiaddrs to give other operators on startup, so there is nothing to assemble by hand.
3. Give both the same validator set
# in BOTH a.yaml and b.yaml, identically:
consensus:
validators:
- 689cf718481d3c13cf4e370526d5228d758e0c2c9b6f5eb3bbc0540c20eede31 # A
- 4b1d0c9a... # B
epoch_length: 100This is the step that decides whether you have one network or two. Every node derives its round-robin leader schedule from this list, so nodes with different lists disagree about who may propose and will never commit each other's blocks - even though they are connected and gossiping. A node's own identity is always added to its set, which is why an empty list is a working single-validator node.
A quorum is more than two thirds of the set. Three validators tolerate none failing, four tolerate one; the useful sizes start at four.
epoch_length must match too. It is how many blocks pass between validator-set changes taking effect, and nodes that disagree about it would switch sets at different heights - see below.
4. Changing an operator-approved set
This section describes membership_mode: operator-approved. There the genesis set changes only after operators approve it. The generated and public launch profile instead uses bonded-open: candidates self-sign admission after their positive bond commits, and active validators self-sign voluntary exits. In either mode, the set in force is committed chain state and resumes from history.
An operator lists a change in their own config. There is no CLI step - the node offers the changes its operator has approved and keeps re-offering them until they commit, so operators can edit their configs one at a time:
# on node A, and on enough other nodes to make a quorum:
consensus:
approved_changes:
- add:9f2c1e4b... # C's consensus identity, from its own startup log
# and to eject one:
consensus:
approved_changes:
- remove:4b1d0c9a...On a node that has approved it, the change commits and then waits:
consensus: committed set change at height 41: add:9f2c1e4b... (takes effect at the next epoch)
consensus: validator set change in force at height 100: add:9f2c1e4b...
consensus: validator set is now 3 members, quorum 3Two properties are worth being explicit about. First, the approval list is a veto: a node prevotes nil on a block carrying a change it has not approved, so a change needs a quorum of operators to have approved it. One validator cannot propose the removal of all the others and have it wave through.
consensus: refusing to vote for a block that would add:9f2c1e4b... (not in consensus.approved_changes)Second, a committed change takes effect at an epoch boundary, a height that is a multiple of epoch_length, not at the moment it commits. That is what keeps every node's leader schedule identical: applying a change as each node happened to reach the block would have nodes disagreeing about who may propose, which is a fork. It also means a change is not instant - with the default epoch_length: 100 it lands within 100 blocks.
A validator caught equivocating - two votes for different blocks at one height, round and phase, both signed by its own key - is removed the same way, but without any config entry. The evidence proves itself, so every node verifies it rather than trusting the peer that relayed it, and there is no operator judgement left to make. Set consensus.eject_equivocators: false if you would rather investigate an offence yourself; detection, recording and gossip carry on either way.
5. Joining a running network
Everything above starts nodes together from the same genesis. That is not how a network grows. A node that arrives late has an empty store: it downloads the chain from its bootstrap peers and replays it from height zero. Two fields in its config have to be exactly right or it never gets off the ground, and the mistakes are quiet ones.
# on the JOINING node, before it starts:
network:
listen_addr: /ip4/0.0.0.0/tcp/9000
bootstrap_peers:
- /ip4/<a running peer>/tcp/9000/p2p/<its peer id>
consensus:
validators: # the GENESIS ids, NOT the set in force now
- 689cf718481d3c13cf4e370526d5228d758e0c2c9b6f5eb3bbc0540c20eede31
- 4b1d0c9a...
epoch_length: 100 # must match every node
genesis: # must match the network's config EXACTLY
reward_pool: 1000000000000000000
# allocations: ...the same named allocations the network started with...consensus.validators must be the GENESIS set, not the current one
This is the counterintuitive one. To accept the block at each height a replaying node checks that its proposer was the leader for that height, evaluated against the validator set as it stood then. So it needs the set the chain started from - the genesis set - to validate early history. It then replays the committed set changes and arrives at the current set by itself, exactly as an existing node does across a restart.
Hand it the set in force now instead and it computes the wrong leader for height 0: the proposer that actually led block 0 is not the leader under today's larger set, so it refuses block 0, nothing after it can link, and the node sits at height 0 forever. There is no error that names the real cause - it just never catches up. Configure the genesis ids and let the node derive the rest.
genesis: must match the network's exactly
The chain carries transactions, never the balances they started from. Genesis allocations and the reward pool are applied from the node's own config at first start, once, and the node then applies the downloaded transactions on top. So a node whose genesis differs from the network's ends up with the same blocks and different balances: it agrees on history and disagrees about money, which is the worst kind of disagreement because nothing looks broken until a balance is read. Copy the network's genesis config verbatim - the same allocations and the same reward_pool.
Getting admitted as a validator
A correctly configured joining node first follows without voting while it replays history. In bonded-open mode, fund its printed consensus account and set a bond at or above the network's positive minimum; the candidate signs and gossips its own admission transaction after that bond commits. No operator allow-list vote is required. In operator-approved mode, use the approved-change flow from section 4 instead.
# the joining node's startup log:
Consensus identity: 9f2c1e4b... # <- give this hex id to the operators
# an existing operator, in their own config, once a quorum of them agree:
consensus:
approved_changes:
- add:9f2c1e4b...epoch_length must match every node here too, for the reason in section 3: it is when set changes take effect, and nodes that disagree about it would switch sets at different heights and fork.
On a staked network, bond before admission
In bonded-open mode, a joining node cannot be admitted until its self-funded positive minimum bond has committed. Fund its consensus account - the id it prints at startup - then set consensus.stake.bond; the node signs the bond and admission itself. In operator-approved mode the bond is still necessary when stake is enabled, and the separate operator approval remains necessary too.
6. Bonded stake
The generated bonded-open baseline turns this on. Voting power becomes an account's bonded native MATRIX, admission requires a positive minimum bond, and a validator proven to have equivocated loses that bond instead of only its place. A private operator-approved network can choose equal voting power instead.
consensus:
membership_mode: bonded-open
participate_in_open_set: true
stake:
enabled: true
min_bond: 1000000000000000 # positive admission floor; must match every node
unbonding_period: 1000
bond: 1000000000000000 # fund THIS node's printed consensus accountA bond is a balance in a reserved account, consensus/stake/bond/<account id>, on the same ledger everything else settles on. So bonding conserves supply, bonded coins leave the spendable balance, and "how much is bonded" is a balance you can read. Bonding has to be signed by the validator's own key, which lives inside the node, which is why bond is a config field rather than a command: the node bonds the shortfall itself and keeps topping it up. Fund its consensus account first - the id is the one it prints at startup - or it says so and does nothing:
Consensus identity: 0c2265316242995a96890c698af0e1959264bb6b1f805ed7352fe6174f6fcabb
Consensus: bonded stake is ON. Voting power is bonded MATRIX; a validator must bond at least
1000 base units to be admitted, and waits 4 blocks after leaving to withdraw.
Consensus: this node will keep 5000 base units bonded from its own account 0c226531...
# before its account is funded:
consensus: this node wants 5000 more bonded but its consensus account (0c226531...) holds
nothing; fund that account before it can validate on a staked network
# after:
consensus: bonding 5000 native base units (bonded 0, target 5000)
consensus: validator set is now 1 members, total power 5000, quorum 3334Why weight the quorum at all: a quorum counted in HEADS can be bought for the price of a few minimum bonds under a few identities, because identities are free and only the bond is not. Weighting by stake prices an attack at two thirds of everything bonded however many identities it is spread across.
Everyone bonds, or nobody is weighted
While ANY validator has bonded nothing, the whole set stays at equal power and the node says so. That is deliberate: an unbonded member counts as 1, so weighting a partly-bonded set would hand essentially the entire voting power to whoever bonded first - and it could not even be slashed, because a slash needs a quorum it would then control. Staying at headcount until the last validator has posted its bond makes the switch atomic. A validator that refuses to bond holds the network at headcount, which is the status quo and something its peers can answer by removing it.
Withdrawing is gated: a sitting validator may not withdraw at all, and after leaving the set it waits unbonding_period blocks. Without that delay a validator could equivocate, be ejected, and pull its bond out before the network committed the slash - so the delay has to be long enough for evidence to be gossiped, voted on and committed. A withdrawal submitted early is not rejected, it simply waits in the mempool and lands by itself.
What bonded-open means
Admission and voluntary exit are permissionless only after the candidate's positive bond and self-signed membership transaction commit. That prices identities but does not make EVM bridge attestors dynamic: WrappedMatrix keeps the fixed committee selected at contract deployment, independently of native validator joins and exits.
7. Launch economics: fee, no emissions
The public overlay charges a 100-basis-point protocol fee, gives the configured maintainer 5000 basis points of that fee, and sets rewards.per_block: 0. Providers publish MATRIX-denominated quotes and earn user payments; launch supply does not grow through provider emissions.
consensus:
fee_basis_points: 100 # 1%, the code cap
maintainer_account: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # EXAMPLE; replace
maintainer_fee_share_basis_points: 5000 # 50% of the fee, not 50% of transferred value
rewards:
per_block: 0 # public launch: no provider token emissionsThe protocol fee
A cut of every value transfer a committed block carries, paid to the validator set pro rata by voting power. It is what makes a bond worth posting: without it, stake is a pure cost and no third party has a reason to put capital at risk. Turn the two on together.
It comes out of the amount, so a transfer of 10,000 at 1% credits the recipient 9,900 and the validators 100. Adding it to the sender instead would make a transfer that was affordable when it was proposed unaffordable when it was applied, so a job priced at exactly the buyer's balance would commit and then be skipped. Providers price for the cut the way they would on any marketplace that takes one.
The rate is capped at 100 basis points in code, not in config validation. A build refuses to start on a higher number, so the worst a mistyped 1000 can do is fail loudly rather than take ten times the intended cut. Every node must agree on the rate: a node charging differently would compute different balances from the same block.
It is paid to the set in force, not to the validators whose votes carried the block. That is not a preference - the commit certificate a node observes is node-specific, so a split that depended on it would give two honest nodes different balances. The cost of the choice is real and worth knowing: it pays for stake rather than for participation, so a validator that never votes still earns, and the answer to that is to remove it.
Provider emissions are zero at launch
matrixd supports a fixed, supply-tracked reward-pool schedule for networks that explicitly choose one, but the public overlay does not: per_block: 0. Providers manually quote their final MATRIX price (or a MATRIX-denominated observed cost plus markup), and a buyer's job snapshots that quote. Manual quotes expire and must be refreshed with matrix provider quote-update --id <provider> --price <price>; run or schedule it before expiry. Do not re-register to refresh: quote-update preserves available capacity and active reservations. There is no stablecoin peg and no DEX oracle silently repricing jobs.
A reservation keeps the exact price-per-unit, quote id/version, observation time and validity window accepted by the buyer. A later provider refresh changes future jobs, not an existing snapshot, and stale quotes are rejected before work starts.
Consensus: a protocol fee of 100 basis points (1.00%) is taken from every value transfer.
Consensus: maintainer receives 5000 basis points of that fee (0.50% of transferred value).
Consensus: provider rewards are OFF; providers earn only user-paid MATRIX.
# a job priced at 10000, settled through consensus:
# provider +9900 (quoted amount less the 1% protocol fee)
# maintainer +50 (half of the fee)
# validators +50 (remainder, pro rata by voting power)
# emissions 0The fee applies to every consensus-settled value transfer
All value now moves through consensus. Marketplace settlement - a compute job, an inference job - and a plain matrix wallet transfer are all submitted as signed transfers that a quorum orders into a committed block and every node applies to the same ledger. So each of them pays the protocol fee the same way, and two nodes agree on the resulting balances. There is no longer a path that moves MATRIX on one node without a quorum or that escapes the fee.
Ports
9000 libp2p peers. Must be reachable by other nodes.
9090 gRPC admin deploy, health, logs.
9091 gRPC market providers, jobs, balances, transfers.
9092 gRPC inference
9093 HTTP the same market and inference services, as JSON, for browsers.Only 9000 needs to be reachable by other nodes. The four API ports are for operators and clients: expose them deliberately, and with security.enable_acls on - with it off, anyone who can reach 9091 can spend the balances this node holds keys for.
Checking it worked
matrix --addr <node>:9091 --api-key <key> statuson each node.- Submit and complete a job against one node, then read the balance from the other. If both agree, they are applying the same committed ledger. If one lags, it is catching up - a node that missed a block fetches it from a peer along with the quorum that committed it.
matrix tx listfrom each node should return the same transactions in the same order.
Next
- Configuration - every field in both files
- Consensus - what the quorum is actually doing