Skip to content
GitHub

How a transaction works

Imagine a shared notebook that everyone in town copies. Every page of it is a list of who paid whom. Nobody owns the notebook. Everybody keeps a copy, and they all agree on the same pages.

A transaction is a signed note that says: “I, the owner of this account, pay 5 coins to my friend.” Four things decide whether the note is written in the notebook:

  1. The lock works. The note carries two signatures, like two different keys turning two different locks. Both must fit. If either is wrong, the note is thrown away.
  2. The note is the next one. Each account numbers its notes 0, 1, 2, and so on, like cheque numbers. A note that reuses an old number is refused. This stops someone from copying your note and paying twice.
  3. You have the coins. You cannot spend more than your account holds.
  4. You paid the postage. Every note costs a small fee, in proportion to its size. The fee is destroyed, so it does not go to anyone.

When enough of the town agrees on a page, that page is final.

wallet node ledger
────── ──── ──────
build body ─► sign ─► mempool ─► DAG anchor commits ─► block ─► stage each tx ─► state root
(inputs, (ML-DSA + (both sigs (ADR-027: block verify sigs (apply in order,
outputs, SLH-DSA) checked derived from fee, nonce, all or nothing)
nonce, fee) early) certificates) balance)

The steps in the order the code runs them, in crates/node/src/state/db.rs, stage_transaction:

#CheckCodeRefusal
1Both signatures verify over the signing bytesTransaction::verify (core/transaction.rs)MissingSignature, SignatureSuite or a verification error
2Fee: outputs to the fee collector cover base_fee × sizecharge_fee (state/fee_exec.rs)FeeTooLow { required, offered }
3Sender: derived from the keys, never from the wiretx.sender()the address is the hash of both keys
4Vault limits, if the sender is a vault account (ADR-030)vault_checkvault error
5Nonce equals the sender’s next noncetx.nonce != sender.nonceInvalidNonce
6Sum of outputs (checked, 256-bit) is covered by the balanceledger_math::total_outputsInsufficientBalance, BalanceOverflow
7Debit the sender, advance the nonce, credit outputsoverlay writesnone, once 1 to 6 pass

A block is applied all-or-nothing. The fee burn is recorded in the overlay and settled before staking runs (settle_fees).

From crates/node/src/core/transaction.rs:

FieldMeaning
inputsreferences (prev_tx, index) to earlier outputs
outputs(amount: U256, recipient: [u8; 32]) pairs, amounts in base units
noncereplay counter; the account’s next expected value
kindwhat it does; TxKind::Transfer adds nothing to the encoding
public_keyboth public keys, ML-DSA-65 and SLH-DSA-SHA2-128s
signatureboth signatures, present or absent, never half

The wire version selects the authorization. Versions 5 and 6 are the hybrid pair. Version 7 is a suite-tagged single signature, accepted only at or past SUITE_ENVELOPE_ACTIVATION_HEIGHT. Version 8 is an m-of-n multisig policy. Versions 1 to 4 are refused by name, because their 3,309-byte single signature is the thing the hybrid exists to rule out (docs/hybrid-signatures.md).

TX_DOMAIN = "custom-l1-node.tx.v4"
signing_bytes = TX_DOMAIN ‖ chain_tag ‖ body_bytes() (signature excluded)
TXID_DOMAIN = "custom-l1-node.txid.v1"
txid = BLAKE3(TXID_DOMAIN ‖ kind ‖ body ‖ σ_ML ‖ σ_SLH) (hybrid)

The txid hashes the signatures too, so re-signing changes the id. Signing is deterministic (see signatures), so one payload signed by one key pair always gives one id.

The chain tag is the genesis block id (ADR-036). A transaction signed for one Maya2C network cannot verify on another. The txid is chain-independent and only deduplicates; verification is what stops replay.

RuleWhere
required = base_fee × size_in_bytes, size = tx.to_bytes().len()fee_exec.rs
The offer is the sum of outputs to FEE_COLLECTORfee_exec.rs
A mining share with no outputs pays no base feefee_exec.rs (ADR-044)
The burned fee leaves circulating supplysettle_fees

Measured on 2026-09-29 through a gateway on a fee-charging chain: a hybrid transfer was 13,255 bytes, so the minimum fee at base fee 1 was 13,255. The wallets’ default, “Standard”, paid twice that (API reference).

A demand-responsive fee market is built and tested in crates/fee-market and switched off: every network runs FeeConfig::DISABLED, and nothing calls the crate from the node. See docs/fee-market.md.

Terminal window
l1-wallet --keystore my.key --rpc-url https://rpc.maya2c.dev/rpc balance
l1-wallet --keystore my.key --rpc-url https://rpc.maya2c.dev/rpc \
send --to <recipient address> --amount 100
# Sign and print the hex without broadcasting:
l1-wallet --keystore my.key send --to <recipient address> --amount 100 --no-broadcast
# Pin the nonce yourself when submitting a burst:
l1-wallet --keystore my.key send --to <recipient address> --amount 1 --nonce 7

Flags are those of bins/l1-wallet/src/main.rs: --keystore (default wallet.key, env L1_WALLET_KEYSTORE), --rpc-url (default http://127.0.0.1:8545, env L1_RPC_URL), and on send --to, --amount, --nonce, --fee, --no-broadcast.

  • Mempool applies the same both-signatures rule earlier: crates/node/src/network/mempool.rs.
  • Block contents are derived from committed certificates, not proposed: consensus::bft::builder, described in consensus.
  • Wallet guidance on nonces and bursts: wallet integration.