panurus

Network Service - Ethereum Deployment Runbook

How to stand up a TMS on an EVM chain with the Ethereum network driver (x/token/services/network/evm), and how to change its public parameters afterwards. The driver implements Approach 2 from the Ethereum implementation guide: the chain stores state and checks an endorser quorum, and correctness is established off-chain by FSC endorsers.

This is the procedure the integration topology automates in integration/nwo/token/evm. If you change one, change the other: the test network deliberately deploys through the same forge script an operator runs, so that the two cannot drift apart.

What gets deployed

Three contracts, in this order.

Contract Scope Purpose
EndorsementVerifier one per endorser set Holds the endorser addresses and the threshold; a pure signature checker.
TokenState (implementation) one per chain The shared logic every TMS delegatecalls. Locked in its own constructor, so it can never hold state itself.
TokenStateFactory one per implementation Clones the implementation and initializes the clone in one transaction.
TokenState (clone) one per TMS The TMS’s actual state: tokens, spent markers, public parameters, processed anchors.

The per-TMS clone is the address nodes are configured with. An EndorsementVerifier and a TokenState implementation can be shared by several TMSs on the same chain; the clone never is.

Before you start

Compile for paris, not shanghai, on chains without PUSH0. Besu’s development network is one of them: a shanghai build reverts every contract creation with no useful message. The project’s foundry.toml already pins evm_version = "paris".

Step 1 - Choose the endorser set and threshold

Each endorsing FSC node needs a secp256k1 key. The address derived from it goes into the EndorsementVerifier; the key stays on that node and signs EIP-712 endorsements.

The threshold must be between 1 and the size of the set. It is enforced in three places that must agree, or transactions fail at whichever one is strictest:

Step 2 - Deploy the contracts

The deploy script takes its inputs from the environment so that CI and NWO can drive it:

cd x/token/services/network/evm/contracts

export EVM_ENDORSERS=0xAAA...,0xBBB...,0xCCC...   # endorser addresses, comma separated
export EVM_THRESHOLD=2                            # signatures required
export EVM_PP0=0x$(xxd -p pp0.bin | tr -d '\n')   # initial public parameters, 0x-prefixed hex
export EVM_GRAPH_HIDING=false                     # true for a graph-hiding token driver

forge script script/Deploy.s.sol:Deploy \
  --rpc-url "$RPC_URL" --broadcast --json \
  --private-key "$DEPLOYER_KEY"

The script creates the verifier and the implementation, then creates the per-TMS clone through the factory, which clones and initializes in a single transaction. It then reads the clone’s state back and fails if the verifier, public-parameters hash, version or graph-hiding mode is not what it asked for.

Both halves matter. initialize is deliberately unguarded - the implementation is locked in its constructor, so only a fresh clone can be seeded - which means a clone created in one transaction and initialized in the next is, in between, a live contract anybody can seed with their own verifier and endorser set. The honest deployer’s initialize then reverts, and a deployer that does not check records an address whose endorser set belongs to someone else. TokenStateFactory removes that window rather than narrowing it. On a permissioned chain the risk is low but not zero; the factory costs one extra contract deployment, so there is no reason to run without it.

Record the TokenState clone address from the output. That is the TMS’s contract.

graphHiding is fixed for the life of the clone and must match the token driver: graph-revealing drivers (fabtoken, zkatdlog) spend content-bound markers, graph-hiding drivers spend serial numbers. A mismatch is not detected at deploy time - it surfaces as every spend failing.

Step 3 - Configure the nodes

Each FSC node gets an evm network block in its token configuration. The full schema is in config.go; the parts that matter for bootstrap:

evm:
  endpoint: http://besu:8545
  chainID: 1337
  contracts:
    tokenState: "0x...."           # the clone from step 2
    endorsementVerifier: "0x...."  # optional; the clone holds the authoritative reference
  endorsement:
    threshold: 2
    endorsers:                     # every endorser, on every node
      - address: "0xAAA..."
        fscIdentity: endorser-1
      - address: "0xBBB..."
        fscIdentity: endorser-2
    allowlist:                      # FSC identities allowed to request endorsement - required, see below
      - endorser-1
      - endorser-2
      - initiator-1
  endorser:                        # only on an endorsing node
    enabled: true
    keystore: /path/to/endorser.key
    address: "0xAAA..."
  submitter:                       # only on a node that broadcasts
    keystore: /path/to/submitter.key
    address: "0xSUB..."
  finality:
    blockTag: finalized            # finalized (default) | safe | latest
    timeout: 20m                   # default 20m; must be >= 13m when blockTag is finalized
    pollInterval: 2s               # default 2s
    conflictGrace: 30s             # default 30s, clamped to timeout; see below
    fromBlock: 0                   # default 0 (search from genesis); set to the deploy block on an older chain

Four things are easy to get wrong here:

Step 4 - Verify the deployment

Before running traffic, confirm the chain agrees with the configuration:

cast call "$TOKEN_STATE" "getPublicParamsVersion()(uint64)"  --rpc-url "$RPC_URL"  # 0
cast call "$TOKEN_STATE" "getPublicParamsHash()(bytes32)"    --rpc-url "$RPC_URL"  # sha256(pp0)
cast call "$TOKEN_STATE" "endorsementVerifier()(address)"    --rpc-url "$RPC_URL"
cast call "$TOKEN_STATE" "graphHiding()(bool)"               --rpc-url "$RPC_URL"

getPublicParamsHash is SHA-256 of the parameter bytes, not keccak.

Updating public parameters

There is no administrative setter. After initialize, the only thing that changes public parameters is an endorsed setup delta: a delta with isSetup set, carrying the new parameters and nothing else - no spends, no outputs, no metadata - signed by the same endorser quorum that authorises a transfer. The contract stores the parameters, increments the version, and emits PublicParametersUpdated.

That is the same authority a transfer needs, deliberately. An operator with a setter could rewrite the issuer set unilaterally; this way parameters change only with endorser agreement.

The procedure:

  1. Produce the new parameters with tokengen.
  2. Collect a quorum of endorsements over the setup delta. The delta asserts the parameters it supersedes, so it must be built against the current version - if another update lands first, it is stale and reverts with StalePublicParams.
  3. Submit it through a funded account and wait for the receipt. A revert here is much easier to read than the timeouts it otherwise causes on every node afterwards.

nwo.SetupUpdater does exactly this for a harness that holds every endorser key. In production the signatures come from the endorsers themselves.

Nodes pick the change up on their own. Each one polls the contract’s version counter (pp.Watcher, once a second by default), and on a change reads the new parameters, reloads its TMS and stores them. There is nothing local to trigger off, since the update was somebody else’s transaction.

Two consequences worth knowing before you debug one of them:

Recovery for transactions left Pending across a restart is not configured under evm at all: settings load from services.network.fabric.recovery, the same key the Fabric and FabricX drivers read, with the SDK’s defaults (enabled, 30s TTL, 5s scan interval) applying when a TMS sets none. See “Transaction recovery across restarts” in the implementation guide for what it does.

Bootstrapping the test network

Four suites exercise this runbook, against two different backends:

All four pull their respective docker image if it is missing.

The topology performs this runbook: boots the node, runs the deploy script above, generates an endorser key per endorsing node plus a funded submitter, and writes each node the configuration from step 3. The rendered configuration is parsed back by the driver’s own LoadConfig in a test, so a harness that writes something the driver cannot read fails there rather than halfway through a suite.

Port conflicts

The suites allocate from a fixed range: 7100 for the zkatdlog suite and 7200 for fabtoken, a hundred ports each. If something on the machine already holds one of those, a node fails to bind and the run dies early with an error that says nothing about the driver.

They used to start at 7000, which macOS binds to AirPlay Receiver by default, so every Mac hit it. The range now skips 7000 for that reason. If you hit a conflict anyway, find the holder with lsof -nP -iTCP:7100 -sTCP:LISTEN (or ss -ltnp on Linux) and either stop it or move the suite range in integration/ports.go.

A container left over from an interrupted run holds its published port too. docker ps -a | grep besu finds it; the suites remove a stale container by name on startup, but only the one they are about to create.

See Also