Skip to content

feat(evm): Besu test network and the fungible suite running on it - #2159

Open
atharrva01 wants to merge 27 commits into
LFDT-Panurus:feature/evm-network-driverfrom
atharrva01:evm-week6a
Open

feat(evm): Besu test network and the fungible suite running on it#2159
atharrva01 wants to merge 27 commits into
LFDT-Panurus:feature/evm-network-driverfrom
atharrva01:evm-week6a

Conversation

@atharrva01

@atharrva01 atharrva01 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Stands up an EVM-backed test network and runs the existing fungible suite against it, with the shared test bodies unchanged.

The network handler boots a Besu node in a container, deploys the contracts through the project's own forge script, generates an endorser identity per endorsing node plus a funded submitter, and writes each node a token configuration pointing at what it just deployed. The rendered config is parsed back by the driver's own LoadConfig in a test, so the harness and the driver cannot drift apart.

On the driver side this fills in what the suite needed and was missing:

  • local membership, so the token drivers can read a default identity while building a TMS
  • a per-TMS endorsement service, resolved lazily from the TMS the network is asked to approve for, since it needs that TMS's validator
  • the public-params fetcher, so a node that starts after the network can catch up
  • endorser registration at startup rather than on first approval, because an endorser answers requests without ever making one
  • a watcher for public-parameters updates: they arrive as an endorsed setup delta somebody else submitted, so there is nothing local to trigger off and a node kept serving what it started with

A few things only showed up once real nodes were talking to each other:

  • the endorsement config names nodes, but a session is opened to an identity and the allowlist is compared against the identity a session authenticated. Names are now resolved through the identity provider, the way the fabric endorsement service does.
  • every node was being handed the whole network's wallets instead of the ones it was issued, which left it without a usable identity of its own
  • Besu does not implement eth_maxPriorityFeePerGas. It is an extension rather than part of the JSON-RPC spec, so the submitter falls back to the part of eth_gasPrice above the base fee.
  • contracts are compiled for paris. Besu's dev network does not support PUSH0, so a shanghai build reverts every contract creation. Golden digests and the forge tests are unchanged by the switch.

Public parameters are updated through an endorsed setup delta, since the TokenState contract has no administrative setter. A factory creates and seeds a clone in one transaction, so there is no window between the two for anyone else to initialize it.

There are two suites, zkatdlog and fabtoken, over the same driver. That is worth the duplication: the two token layers put very different work through one network driver, so a failure under one and not the other says where the problem is not. make integration-tests-evm and make integration-tests-evm-fabtoken run them. They need docker and forge, and pull the besu image if missing. No FAB_BINS.

The fabtoken suite passes end to end on a real Besu node with fungible.TestAll unmodified. The zkatdlog one still stops on a time-window assertion in CheckAuditedTransactions; details are in a comment below, and it is being tracked separately rather than held against this branch.

There is also a deployment runbook under docs/services/network-ethereum-deployment.md.

…r wiring

Completes the driver so it can be built from configuration alone.

A recipient only ever holds the token request anchor, so it needs a way to find
the transaction that applied it. That comes from the TokenState's commit event,
filtered on the indexed anchor: the transaction hash is not in the payload and
cannot be, since a contract has no way to read its own hash, so it is taken from
the log record the node returns. The cheaper getTokenRequestHash call stays the
common check, because log queries need a block range and retention varies
between nodes.

Local membership is injected rather than invented, since the driver does not own
identities, and the submitter is now built from the configured key, with the
derived address checked against the configured one so a mismatched pair fails at
startup instead of producing signatures nobody accepts.

Also splits the EIP-712 type string across lines for the newly enabled line
length linter. The concatenation is compile time and the golden digests are
unchanged.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Adds evmdlog, the node SDK for a TMS backed by an EVM network, mirroring fdlog
and fxdlog. It registers the EVM driver in the same network-drivers group the
fabric drivers use, so the network provider picks whichever one recognises a
network from its configuration.

It lives in the integration module rather than the driver's own module because
wiring needs token/sdk/dig, which pulls in core's fabric and idemix graph. The
driver is a separate module kept lean enough to live in its own repository, and
that graph cannot be version reconciled inside it. The dependency runs one way:
this imports the driver, never the reverse.

The wiring test resolves the whole graph, so a driver the container cannot build
fails here rather than at node start with a network configured.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The seam where the test network and the driver have to agree: NWO generates a
node's token configuration, and the driver has to accept it. The fabric
extension turns out to be almost entirely backend agnostic, since the TMS
coordinates, certification, storage and wallets are token-level concerns, so
this mirrors it and swaps only the network block for the evm schema.

The round trip test renders the template and loads the result through the
driver's own LoadConfig rather than comparing against a hand written
expectation, which would only prove the template matches itself. It covers a
node that endorses and broadcasts, one that does neither but still needs the
endorser set to route requests, and the finality defaults.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Starts a real Besu in a container and waits until it answers JSON-RPC, so a
caller gets a node that is actually usable rather than one whose container
merely started. It follows the container lifecycle the fabricx extension uses.

Besu rather than a lighter development chain because it is the acceptance
backend: the point of the integration suite is that the driver works against the
real node. It runs in development mode, which mines instantly and pre-funds
accounts, so only consensus and funding are shortcut, not the client behaviour
the driver exercises.

Verified against a real container: the node comes up in about five seconds and
the driver's own client reads the chain id back from it.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…ities

Stands a TMS up on a running node using the project's own forge script, so the
test network deploys the way an operator would rather than through a parallel
path that could drift.

Endorser identities are generated fresh per network, since an endorser only ever
signs digests off chain and never sends a transaction, so its key needs no
funding. The submitter is a pre-funded development account, because it does pay
for gas.

Also compiles the contracts below Shanghai. Besu's development network does not
enable EIP-3855, so a PUSH0 in the deployment bytecode made every contract
creation revert on chain, which is how this surfaced: the transactions were
mined and failed. Nothing here needs a post-Paris opcode, and the 47 contract
tests and the cross-implementation digests are unchanged.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Assembles the pieces into the handler the token platform drives, and registers
evm as a selectable backend alongside fabric and fabricx.

Generating artifacts brings the chain up, provisions endorser identities and
deploys the contracts, all before any node configuration is rendered. That
ordering is forced: a node's configuration has to name the contract addresses,
and those do not exist until the deployment has run. PostRun consequently has
nothing left to do.

Issuer and owner wallets are generated the same way every backend generates
them, since those identities belong to the token layer rather than the chain, so
only the genuinely EVM specific parts are supplied here. Updating public
parameters is refused: after bootstrap they belong to the endorser quorum and an
update travels as an endorsed setup delta, not as a test network action.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Closes the two gaps that stopped a node from building a TMS on an EVM network,
both found by running the suite rather than by reading the code.

The token drivers read LocalMembership().DefaultIdentity() while constructing a
TMS, and the driver returned nil, so a node panicked at startup. Identities now
come from the FSC identity provider, which owns them. Anonymous identities
return the default one: EVM has no network-layer anonymity to draw on, and token
privacy comes from the owner wallets regardless.

Normalize also has to install the public parameters fetcher. The token layer
looks for parameters in the options, its storage, the local configuration and
finally that fetcher, so without it a fresh node cannot find them at all and the
failure surfaces far from its cause.

Adds the per-TMS endorsement service factory. The service cannot be built per
network because validating a request needs that TMS's validator; it takes the
TMS from the approval path rather than reaching for a provider, since a TMS is
built through the network driver and reaching back would close a cycle. Public
parameters for a delta are read from the contract, so the hash and version an
endorser signs are consistent with what the contract checks at apply time.

Also registers the suite: the evm case in the topology switch, a platform for
the infrastructure to resolve, the fungible wrapper calling the shared test
bodies unchanged, and a make target that pulls the besu image.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
An endorser answers requests without ever making one, so registering its
responder lazily on the approval path meant it never registered at all and every
request to it timed out. Registration now happens when the network is built.

That in turn means the responder cannot be tied to a TMS at construction, since
it has to exist before its TMS has necessarily been built, and building one goes
through this driver. It now resolves the TMS from the id the request carries, and
refuses a request for a TMS it cannot resolve, which is the same check the fixed
identity used to make, expressed through what it can actually validate.

Verified on the integration network: all three endorser nodes register.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The endorsement configuration names nodes, but a session is opened to an
identity and the allowlist is compared against the identity a session
authenticated. Building both straight from the configured strings meant the
initiator opened sessions to nothing and the endorser could never match a
caller, so every request timed out.

Resolve the names through the FSC identity provider, the way the fabric
endorsement service already does. An endorser that cannot be resolved is fatal,
since the quorum could never be met; an allowlist entry that cannot be resolved
is dropped, since it could never match anyway and a node does not resolve its
own name.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
… the node has no opinion

Two things stood between the test network and a working issue.

The token configuration listed the whole network's wallets on every node
instead of the wallets that node was issued, which left each of them without a
usable identity of its own. The per-node wallets are tracked by the materials
handler, so take them from there.

Besu does not implement eth_maxPriorityFeePerGas. It is a de-facto extension
rather than part of the JSON-RPC spec, so a node is entitled not to, and the
submitter treated its absence as fatal. Fall back to the part of eth_gasPrice
that sits above the base fee, which is the same quantity computed from a method
every node implements.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…ough an endorsed setup delta

The topology conflated the network's name with the technology backing it, so an
EVM network came up as "evm" while the suites address networks as "default",
the name the fabric and fabricx topologies use. Type still says evm, which is
what the token platform routes on.

The TokenState contract has no administrative setter for public parameters:
the only way they change is an endorsed setup delta. The test network holds
every endorser key, so it can produce the quorum the contract asks for, which
is what the operator of a real network would have to do too.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…token too

Public parameters change through an endorsed setup delta that some other node
submits, so unlike a transaction this node sent there is nothing local to
trigger off, and a node kept serving whatever it started with. Watch the
version counter the TokenState contract keeps and reload the TMS when it moves,
which is the same job fabric's listener on the setup key does.

It polls the version rather than subscribing to the log. The version is one
cheap call against current state, so there is no from-block to carry across
restarts and nothing to reconcile after a reorg.

Also adds an evmfabtoken SDK and the matching suite, so the same shared test
bodies run over the EVM driver with both token drivers. That is worth the
duplication: the two put very different work through one network driver, so a
failure under one and not the other says where the problem is not.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The unaligned keys in the FuncMap literal fail 'gofmt -l -s', which fails
make checks, which gates both utest and make lint in the same job. Nothing
on the branch had been linted or unit tested by CI because of it.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
A node learns that a transaction it holds became final through a finality
listener the ttx layer registers when it stores that transaction. That
registration lives in memory. A node that restarts in between is left with a row
stuck at Pending and nothing that will ever move it: the chain has the answer and
nobody is asking, so every later wait on that transaction runs to its timeout.

Start the SDK's recovery manager over the transaction store and the audit store,
per TMS, from Connect. The Fabric driver starts exactly these two for the same
reason and fabricx inherits them; this driver started none, which stays invisible
for as long as no node restarts.

Failing to start it is logged rather than returned. The node works without
recovery, and refusing the connection would take it out over a facility it needs
only for transactions it may not have.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Estimating gas executes the transaction, so an estimate that reverts is the chain
rejecting the delta: a double spend, stale public parameters, a quorum the
contract will not accept. Sending it anyway mines it with status 0 and reaches
the same verdict, having paid for it. Every other failure of the same call says
nothing about the transaction and has to be retried instead.

Both came back as one wrapped error, so a caller could only tell them apart by
matching strings. Classify at the client, where the JSON-RPC code and message are
still in hand: the code alone is not enough, since -32000 is the
implementation-defined range and covers every server-side execution failure, and
the message alone is not enough, since clients word it differently. Pair the two.

The driver then wraps a revert as ErrTransactionReverted and the rest as
ErrNetworkUnavailable, which is the permanent/transient split callers act on.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Two instances of one mistake: state that a parameters update replaces, captured
by something that outlives the update.

ChainProvider read the parameter bytes fresh but took the version from a keeper
only the watcher invalidated. The pair travels together in a delta and the
contract checks both, so after an update every delta carried the new bytes under
the old version and was rejected with StalePublicParams, for as long as the cache
lived. It surfaced only as eth_estimateGas reverting, with nothing pointing at
parameters. Both halves are read per call now, at one extra eth_call.

The initiator captured a *token.ManagementService, which Update evicts so the
next caller builds a new one. The captured pointer kept validating against
superseded parameters, so after an update authorising a new issuer that issuer's
every request was rejected as unauthorized, by a node that had already logged the
new parameters. It holds a TMS id and resolves per request now, as the responder
always did.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The harness handed every node the same pre-funded development account. Nonces are
per account and each node's NonceManager counts locally, so the second node to
broadcast reused a consumed nonce and got nonce too low. The harness's own
parameters update, spending from that same account, moved the nodes' nonces
behind their backs in the same way.

Generate and fund an account per node, and keep a separate operator account for
deploying and for submitting the setup delta: that is the harness acting as the
network's operator rather than as any node, and spending from a node's account
would move that node's nonce without telling it.

Funding has to run after the chain is up, which is why it is not next to the
endorser identities.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Deploying in two transactions leaves a window between the clone and its
initialize in which anyone can initialize it first, and the deployer then records
an address whose endorser set belongs to someone else.

TokenStateFactory closes it by doing both in one call. Deploy.s.sol also reads
the post-initialize state back before recording the address, so a deploy that
somehow lands wrong is caught at deploy time rather than at the first
transaction.

The test suite pins both halves, including running the hijack against the
two-transaction deploy this replaces, so the window it closes is demonstrated
rather than asserted.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The runbook enumerates the bootstrap an operator performs: deploy the verifier
and the TokenState clone, seed public parameters, register the endorser set,
threshold and graphHiding. It is the spec the forge and NWO scripts automate.

The design doc catches up with what the integration run changed, per the rule
that deviations land as design edits rather than as commit messages: nothing
cached across a parameters update, transaction recovery, Besu's JSON-RPC gaps and
the paris compile target, and which of the error taxonomy is built.

network-ethereum.md gains an operational section, since error classes, recovery
and one funded account per submitting node are things an operator has to know
before running this and could previously only learn by reading the driver.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…ries

The goimports failure on topology.go is what CI was red on; it short-circuited
make checks before staticcheck ever ran, which was hiding two more.

weekly sync.html was never meant to be committed.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
A revert writes nothing to the chain, so an anchor that never appears is all a
rejection ever looks like. The design says an unseen anchor is Unknown, then
Invalid after the configured timeout. That escalation existed in AddListener,
which knows when it started waiting, and not in StatusByAnchor, which is what
GetTransactionStatus and therefore recovery call.

The shared recovery handler reads Unknown as transient and looks again next
sweep, so a rejected transaction was claimed every five seconds forever, each
sweep logging success and changing nothing. Its record stayed Pending and the
holding it had reserved was never released, which is what the suite saw: the
right balance next to a stale holding.

Recovery now resolves a still-absent anchor to Invalid. What makes that safe is
when it is asked: the manager only claims rows older than the TTL, and the TTL is
raised to the finality timeout, so the database has already established that the
transaction outlived the window in which one is ever awaited. That clock is a
column rather than a field, so it survives the restart recovery exists for.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
The previous commit licensed recovery's invalid verdict by raising the recovery
TTL to the finality timeout, so the manager only claimed rows old enough to
condemn. That also delayed the committed case, which is the one where the chain
had the answer all along: a recipient whose transfer had landed was no longer
recovered within the thirty seconds a finality check allows, and the suite failed
an assertion earlier than before.

The TTL answers how soon it is worth asking again. The finality timeout answers
how long absence means rejection. Reading the transaction row's own timestamp
keeps the sweep frequent and the verdict patient, and it is still a column rather
than a field, so it survives the restart recovery exists for.

A store that cannot be read leaves the transaction in the sweep rather than
condemning it, since that is the one irreversible move available here.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…is what it is

The fabtoken suite runs green end to end on Besu with the shared bodies
unmodified, including the concurrent transfers and the parallel token selector
that no earlier run had reached.

The timeout comment is the part worth keeping. Two minutes looks absurd next to a
chain that mines instantly, and shortening it to match the chain deletes
transfers: a transaction that has been prepared but not yet broadcast has no
anchor on chain, which is the same evidence a rejected one leaves, so recovery
condemns it. The suite prepares two transfers, restarts two nodes, and only then
broadcasts. The binding lower bound is not the chain's finality, it is the
longest gap the application leaves between assembling a transaction and sending
it.

The design already said an absent anchor is indistinguishable from one that was
never submitted. It said so in the section that then treated absence past a
deadline as proof of rejection.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
ginkgo -cover writes coverprofile.out next to the suite it ran, and it was
committed. It is a build artifact of whoever ran the suite last.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
testifylint wants require for error assertions, and these three are the ones
that say which class a failure belongs to. Continuing past a wrong class only
produces a second, more confusing failure on the next line.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
…e design doc on deployment

AGENTS.md asks for a fuzz target on every exported function that parses
untrusted input, and the evm module had none while the rest of the repo has
twenty six. The driver is mostly parsers: endorsement signatures from remote
peers, envelopes off the wire, ABI responses from a node, hex from
configuration.

Nine targets over the entry points that take bytes somebody else chose. They
check more than the absence of a panic, since that alone would pass on a
decoder that quietly truncates: a recovered signature has to satisfy the same
format rules the verifier contract enforces, a decoder must not return more
than it was given, a parsed address has to round trip, and moving a byte across
the nonce and creator boundary has to change the anchor.

Wiring them in needed a working directory in the nightly matrix, since the evm
module is its own and go test cannot reach it from the repository root. The
crash collection step needed the same, or a failing evm target would have
uploaded no reproducer.

Design §3.8 still described the deploy-hardening factory as future work. It
exists, the deploy script goes through it, and the post-initialize check that
paragraph called for now guards against duller failures than hijacking.

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant