This guide describes how to implement a Network Service driver for Ethereum-based networks. Panurus’s driver architecture enables integration with Ethereum and EVM-compatible blockchains through two distinct approaches, each with different trade-offs.
Ethereum integration requires adapting Panurus’s transaction model to Ethereum’s account-based ledger and smart contract execution environment. Unlike Fabric’s channel-based architecture, Ethereum uses a global state model with smart contracts for business logic execution.
graph TB
subgraph "Panurus"
App[Application/TTX]
Driver[Ethereum Network Driver]
end
subgraph "Ethereum Network Driver"
Node[Ethereum Node]
Contract[Token Smart Contract]
State[(Blockchain State)]
end
App -->|Token Request| Driver
Driver -->|Transaction| Node
Node -->|Execute| Contract
Contract -->|Update| State
State -->|Events| Driver
Driver -->|Finality| App
Panurus supports two architectural approaches for Ethereum integration, each suited to different requirements:
The smart contract performs full validation logic, similar to Fabric’s token chaincode model.
Architecture:
graph LR
subgraph "FSC Node"
App[Application]
Driver[Ethereum Network Driver]
end
subgraph "Ethereum Network Driver"
Node[Ethereum Node]
SC[Token Smart Contract]
State[(State)]
end
App -->|1. Token Request| Driver
Driver -->|2. Submit Transaction| Node
Node -->|3. Execute Contract| SC
SC -->|4. Validate Request| SC
SC -->|5. Check Double Spend| State
SC -->|6. Update State| State
State -->|7. Event| Driver
Driver -->|8. Finality| App
Characteristics:
FSC nodes perform validation off-chain and endorse state updates, similar to FabricX model.
Architecture:
graph TB
subgraph "Initiator FSC Node"
App[Application]
DriverI[Ethereum Network Driver]
end
subgraph "Endorser FSC Nodes"
E1[Endorser 1]
E2[Endorser 2]
Val1[Validator]
Val2[Validator]
end
subgraph "Ethereum Network"
Node[Ethereum Node]
SC[Token Smart Contract]
State[(State)]
end
App -->|1. Request| DriverI
DriverI -->|2. Request Endorsement| E1
DriverI -->|2. Request Endorsement| E2
E1 -->|3. Validate| Val1
E2 -->|3. Validate| Val2
Val1 -->|4. Sign State Update| DriverI
Val2 -->|4. Sign State Update| DriverI
DriverI -->|5. Submit Tx + Signatures| Node
Node -->|6. Execute| SC
SC -->|7. Verify Signatures| SC
SC -->|8. Double Spending Check</br>Apply State Update| State
State -->|9. Event| DriverI
DriverI -->|10. Finality| App
Characteristics:
| Aspect | Approach 1: Smart Contract Validation | Approach 2: Pre-Order Execution |
|---|---|---|
| Validation Location | On-chain (in smart contract) | Off-chain (in FSC nodes) |
| Gas Costs | Higher (full validation on-chain) | Lower (only signature verification) |
| Complexity | Simpler (single-tier) | More complex (two-tier) |
| Flexibility | Limited by EVM constraints | High (validation in Go) |
| Endorsement | Not required | Required from FSC endorsers |
| Best For | Simple deployments, public tokens | Complex validation, privacy needs |
Both approaches must implement the driver.Network interface:
type EthereumNetwork struct {
client EthereumClient
contract TokenContract
chainID *big.Int
// Approach 2 specific
endorsers []Endorser // Only for Approach 2
}
// Core interface methods
func (n *EthereumNetwork) Name() string
func (n *EthereumNetwork) Channel() string
func (n *EthereumNetwork) Broadcast(ctx context.Context, blob interface{}) error
func (n *EthereumNetwork) RequestApproval(...) (driver.Envelope, error)
func (n *EthereumNetwork) ComputeTxID(id *driver.TxID) string
func (n *EthereumNetwork) FetchPublicParameters(namespace string) ([]byte, error)
func (n *EthereumNetwork) AddFinalityListener(...) error
sequenceDiagram
participant App as Application
participant Driver as Ethereum Network Driver
participant Node as Ethereum Node
participant Contract as Token Contract
participant State as Blockchain State
Note over App,State: Token Transfer Example
App->>Driver: Transfer(tokens, recipient)
Driver->>Driver: Create transaction data
Driver->>Driver: Sign transaction
Driver->>Node: eth_sendRawTransaction
Node->>Contract: Execute transfer()
Contract->>Contract: Decode token request
Contract->>Contract: Validate signatures
Contract->>State: Check input tokens exist
Contract->>State: Check not double-spent
Contract->>Contract: Validate business logic
Contract->>State: Mark inputs as spent
Contract->>State: Create output tokens
Contract->>Contract: Emit TransferEvent
Node->>Node: Include in block
Node->>Node: Mine block
State-->>Driver: Event notification
Driver->>Driver: Confirm finality
Driver-->>App: OnStatus(VALID)
// Conceptual interface - not production code
interface ITokenContract {
// Process a token request
function processRequest(
bytes calldata tokenRequest,
bytes[] calldata signatures
) external returns (bool);
// Query functions
function getToken(bytes32 tokenId) external view returns (bytes memory);
function isSpent(bytes32 tokenId) external view returns (bool);
function getPublicParameters() external view returns (bytes memory);
// Events
event TokenRequest(bytes32 indexed txId, bool success);
event TokenCreated(bytes32 indexed tokenId, address owner);
event TokenSpent(bytes32 indexed tokenId);
}
Transaction Construction:
func (n *EthereumNetwork) RequestApproval(
ctx view.Context,
tms *token.ManagementService,
requestRaw []byte,
signer view.Identity,
txID driver.TxID,
) (driver.Envelope, error) {
// 1. Encode token request for contract call
data := encodeContractCall("processRequest", requestRaw)
// 2. Create Ethereum transaction
tx := types.NewTransaction(
nonce,
contractAddress,
value,
gasLimit,
gasPrice,
data,
)
// 3. Sign transaction
signedTx, err := types.SignTx(tx, signer, chainID)
// 4. Return as envelope
return &EthereumEnvelope{tx: signedTx}, nil
}
Finality Tracking:
func (n *EthereumNetwork) AddFinalityListener(
namespace string,
txID string,
listener driver.FinalityListener,
) error {
// Subscribe to contract events
eventChan := make(chan *TokenRequestEvent)
sub, err := n.contract.WatchTokenRequest(eventChan, txID)
// Monitor for finality
go func() {
event := <-eventChan
status := driver.Valid
if !event.Success {
status = driver.Invalid
}
listener.OnStatus(ctx, txID, status, "", nil)
}()
return nil
}
sequenceDiagram
participant App as Application
participant Driver as Ethereum Network Driver
participant E1 as Endorser 1
participant E2 as Endorser 2
participant Node as Ethereum Node
participant Contract as Token Contract
participant State as Blockchain State
Note over App,State: Token Transfer with Pre-Order Execution
App->>Driver: Transfer(tokens, recipient)
Driver->>Driver: Create token request
Note over Driver,E2: Off-Chain Validation Phase
par Collect Endorsements
Driver->>E1: Request endorsement
E1->>E1: Validate token request
E1->>E1: Compute state delta
E1->>E1: Sign state delta
E1-->>Driver: State delta + signature 1
and
Driver->>E2: Request endorsement
E2->>E2: Validate token request
E2->>E2: Compute state delta
E2->>E2: Sign state delta
E2-->>Driver: State delta + signature 2
end
Driver->>Driver: Check the deltas agree, assemble with signatures
Note over Driver,State: On-Chain Execution Phase
Driver->>Node: eth_sendRawTransaction
Node->>Contract: Execute applyStateDelta()
Contract->>Contract: Recompute the EIP-712 digest
Contract->>Contract: Verify endorser quorum
Contract->>Contract: Check public parameters are current
Contract->>State: Apply state delta
Contract->>Contract: Emit StateCommitted
Node->>Node: Include in block
Node->>Node: Mine block
State-->>Driver: Event notification
Driver-->>App: OnStatus(VALID)
This section describes the contracts as built. Every name, error and check order below comes from
contracts/src; the conceptual sketch further down
the page predates them.
The single most important thing to understand is that the chain does not validate token requests. Zero-knowledge proofs are not verified on chain, signatures on the token request are not checked on chain, and balances are not recomputed on chain. All of that happens off chain, in FSC endorsers running the same Token SDK validator that a Fabric deployment uses.
What reaches the chain is a StateDelta: a flat list of storage writes that the endorsers agreed on,
plus their signatures over it. The contract’s job is narrow and cheap.
flowchart LR
subgraph off ["Off chain"]
direction TB
R["Token request<br/>proofs, signatures, amounts"]
E["Endorsers<br/>each validates independently<br/>and derives the same StateDelta"]
R --> E
end
subgraph on ["On chain"]
direction TB
T["TokenState<br/>checks the signatures<br/>applies the writes"]
end
E -->|"StateDelta + N signatures"| T
style off fill:#f6f8fa,stroke:#d1d9e0,color:#1f2328
style on fill:#ddf4ff,stroke:#54aeff,color:#1f2328
This is why the contracts are small. They never see a proof. They answer one question: did enough authorized endorsers sign this exact set of writes, and are those writes still valid to apply?
Everything below revolves around this struct, so it is worth reading once. It is defined in
StateDelta.sol and mirrored exactly
by the Go type in evm/statedelta. Field order is frozen: the EIP-712 hash depends on it, and every
endorser signs over that hash.
| Field | What it is | Who consumes it |
|---|---|---|
anchor |
The token-request id, SHA-256(nonce‖creator). Not the Ethereum transaction hash. |
Replay guard, and the key a recipient watches for |
spentRefs |
The references being consumed. Meaning depends on the contract’s mode, see below. | Double-spend check |
outputs |
New tokens: tokenID, snMarker, and the opaque tokenData from the token driver |
Written to storage |
metadataKeys / metadataVals |
Aligned lists, keys sorted ascending so every endorser builds identical bytes | Written once, never overwritten |
tokenRequestHash |
SHA-256 of the token request, matching what the rest of the Token SDK stores |
Recorded against the anchor |
publicParamsHash / publicParamsVersion |
The parameters the endorsers validated against | Staleness check |
isSetup |
Marks a parameters-update delta rather than a token transfer | Selects which branch applies |
setupParameters |
The new public parameters, present only when isSetup |
Stored by the setup branch |
Two things about this struct trip people up.
anchor is not the transaction hash. A contract cannot read its own transaction hash, so the driver
identifies transactions by the anchor it derived off chain. This is why finality is resolved by scanning
for an event keyed on the anchor rather than by looking up a transaction.
spentRefs means one of two things, fixed per contract. With graphHiding off, each ref is a
content-bound marker that must already exist and be unspent; because the marker commits to the token’s
bytes, a spender cannot substitute different content at the same position. With graphHiding on, each
ref is a serial number that simply must not have been seen before, which reveals nothing about which
output is being consumed. One TMS runs one token driver, so the mode is chosen at deployment and never
changes.
contracts/src holds six files, but only four of them become contracts at an address. The other two are
libraries whose functions are all internal, which the Solidity compiler inlines into whoever calls them,
so they never get deployed on their own.
flowchart TB
subgraph dep ["Deployed to an address"]
direction TB
TSI["TokenState.sol<br/>implementation, deployed once"]
TSC["TokenState clone<br/>one per TMS, holds all storage"]
EV["EndorsementVerifier.sol<br/>endorser set and threshold"]
FAC["TokenStateFactory.sol<br/>deploy time only"]
end
subgraph inl ["Libraries, inlined into the caller"]
direction TB
E712["EIP712.sol<br/>compiled into TokenState"]
CL["Clones.sol<br/>compiled into TokenStateFactory"]
end
subgraph typ ["Types only, no code"]
SD["StateDelta.sol<br/>two structs, field order frozen"]
end
style dep fill:#ddf4ff,stroke:#54aeff,color:#1f2328
style inl fill:#fff8c5,stroke:#d4a72c,color:#1f2328
style typ fill:#f6f8fa,stroke:#d1d9e0,color:#1f2328
StateDelta.sol is compiled into both TokenState and EIP712. Its field order is frozen because the
EIP-712 hash is computed from it and every endorser signs that hash, so reordering a field would silently
invalidate every signature.
The two libraries are worth being explicit about, because their names suggest more than they are.
Clones.sol is production code, not test scaffolding: TokenStateFactory.create calls
Clones.clone(implementation) to create each per-TMS clone. It also appears in the contract tests, which
clone directly so they exercise the same shape production uses.
Run once per network, from
Deploy.s.sol. Three contracts are
created, and then the factory produces the clone that a TMS will actually use.
sequenceDiagram
autonumber
participant S as Deploy script
participant EV as EndorsementVerifier
participant I as TokenState implementation
participant F as TokenStateFactory
participant C as TokenState clone
S->>EV: new EndorsementVerifier(endorsers, threshold)
S->>I: new TokenState()
Note over I: locks itself in its constructor,<br/>so only a clone can be initialized
S->>F: new TokenStateFactory(implementation)
S->>F: create(verifier, deployer, pp0, graphHiding)
F->>C: Clones.clone deploys an EIP-1167 proxy
F->>C: initialize(verifier, deployer, pp0, graphHiding)
Note over F,C: both in one transaction, so an<br/>uninitialized clone is never reachable
F-->>S: clone address
S->>C: read back verifier, params hash, version, mode, deployer
Cloning and initializing in one call is deliberate. initialize is deliberately unguarded, because the
implementation is locked and only a fresh clone can ever be initialized. Doing it in two transactions
would leave a window in which anyone could seize the clone by initializing it first with their own
verifier, and the honest deployer’s call would then revert with AlreadyInitialized.
Each TMS gets its own EIP-1167 minimal proxy, so a new TMS costs a proxy rather than a full deployment while sharing one copy of the logic. Every clone has its own storage.
Three addresses take part, but only one of them holds any state.
flowchart LR
D["Driver"] -->|"applyStateDelta<br/>delta + signatures"| C
subgraph clone ["TokenState clone: the address, and all the storage"]
C["EIP-1167 proxy,<br/>almost no code of its own"]
end
subgraph impl ["TokenState implementation: the code, no storage of its own"]
direction TB
E["applyStateDelta,<br/>running on the clone's storage"] --> H["EIP712, inlined here:<br/>recompute the digest"]
H --> A["apply the writes"]
end
C -->|"delegatecall"| E
H -->|"verify(digest, signatures)"| V["EndorsementVerifier"]
V -->|"accepted, or revert"| H
A -->|"emit StateCommitted"| L["Logs, at the clone's address"]
style clone fill:#ddf4ff,stroke:#54aeff,color:#1f2328
style impl fill:#f6f8fa,stroke:#d1d9e0,color:#1f2328
style V fill:#dafbe1,stroke:#4ac26b,color:#1f2328
Two things there are easy to get wrong when reading the sources.
The code does not live where the storage lives. The clone owns the address, the token map, the spent
markers and the public parameters, but it is a minimal proxy and carries almost no logic. Every call to it
delegatecalls the shared implementation, which then executes against the clone’s storage. So
applyStateDelta is the implementation’s code running as though it were the clone.
EIP712 is not a contract. Its functions are all internal, so the compiler copies them into
TokenState at build time. Recomputing the digest costs no external call, and there is no EIP712
address to look up on a block explorer.
That leaves exactly one call crossing an address boundary while a transaction is processed: TokenState
asking EndorsementVerifier to check the quorum. TokenStateFactory and Clones play no part at all
once deployment is over.
Because the writes and the event happen in the clone’s context, StateCommitted is emitted at the
clone’s address, and that is the address the finality layer filters eth_getLogs on.
sequenceDiagram
autonumber
participant D as Driver
participant N as EVM node
participant TS as TokenState
participant EV as EndorsementVerifier
Note over D: delta and signatures already<br/>collected from endorsers
D->>N: eth_sendRawTransaction
N->>TS: applyStateDelta(delta, signatures)
TS->>TS: cheap rejects first:<br/>initialized, anchor unseen, lists aligned
TS->>TS: recompute the EIP-712 digest<br/>from the delta in calldata
TS->>EV: verify(digest, signatures)
EV-->>TS: accepted, or revert with the reason
TS->>TS: are the public parameters still current?
TS->>TS: apply the writes
TS-->>N: emit StateCommitted(anchor)
N-->>D: receipt, status 1
Step 4 is the one worth pausing on. TokenState computes the digest itself, from the delta sitting in
calldata. The digest is never passed in as an argument. That is what makes the endorsers’ signatures
binding: they signed the same typed structure the contract is about to apply. The digest also folds in a
domain separator bound to this chain id and this contract address, so signatures gathered for one TMS
cannot be replayed against another.
On Fabric, the read-dependencies gathered during validation (checkInputs, AddPublicParamsDependency)
are re-validated at commit time by MVCC: if a spent token or the public-parameters version changed between
validation and commit, the block’s own read-set conflict check catches it. The EVM chain has no read-set —
there is nothing between “the endorsers signed this” and “the chain applies it” that would catch a change
in the meantime on its own. applyStateDelta is that missing re-validation, made explicit: it re-checks
spend state and the public-parameters version itself, in the contract, at apply time. The gates below are
what that re-validation looks like.
applyStateDelta runs these gates in sequence. There is no partial success: any failure reverts the whole
transaction, so state is either fully applied or completely untouched.
flowchart TB
G1["1 - contract is initialized"] --> G2["2 - anchor has never been processed"]
G2 --> G3["3 - metadata keys and values are the same length"]
G3 --> G4["4 - endorser quorum verifies against the digest"]
G4 --> G5["5 - public parameters are still current"]
G5 --> G6{"isSetup?"}
G6 -->|"yes"| S["store new parameters<br/>bump the version"]
G6 -->|"no"| T["consume spentRefs<br/>write outputs<br/>write metadata"]
S --> Z["record the request hash<br/>mark the anchor processed<br/>emit StateCommitted"]
T --> Z
style Z fill:#dafbe1,stroke:#4ac26b,color:#1f2328
The order is deliberate: the cheap rejections come before signature recovery, which is the expensive part, so a replayed or malformed delta costs as little gas as possible.
Every gate reverts with its own typed error, so a failed receipt tells you exactly what happened. This is the table to reach for when a transaction reverts.
| Error | Meaning | Usual cause |
|---|---|---|
NotInitialized |
The clone was never seeded | Deployment did not go through the factory |
AnchorAlreadyProcessed |
This anchor was applied before | A resubmitted transaction, or a genuine replay attempt |
MetadataLengthMismatch |
Key and value lists differ in length | A malformed delta; the endorser’s translator builds these aligned |
InsufficientEndorsements |
Fewer signatures than the threshold | An endorser was unreachable when the quorum was collected |
UnauthorizedSigner |
A signature came from a non-endorser | The driver’s endorser set has drifted from the contract’s |
DuplicateSigner |
The same endorser signed twice | A broken initiator; N signatures from one endorser are not N endorsements |
InvalidSignatureLength |
A signature was not 65 bytes | A malformed or truncated signature |
StalePublicParams |
The delta names parameters that are no longer current | Someone updated the parameters between endorsement and submission; re-endorse |
InputMissingOrSpent |
A consumed marker does not exist, or is already spent | A double spend, or a token whose content does not match what was recorded |
DoubleSpend |
A serial number was already used (graph-hiding mode) | A double spend |
MetadataKeyOccupied |
A metadata key was already written | A reused key, for example an HTLC claim seen twice |
MalformedSetupDelta |
A setup delta carried spends, outputs or metadata, or no parameters | A driver bug; setup deltas carry only new parameters |
MalformedTransferDelta |
A transfer delta carried setup parameters | A driver bug |
UnsupportedForGraphHiding |
isSpent/areTokensSpent called on a graph-hiding clone |
Spend state there is keyed by serial number, not token ID; use isSerialUsed instead |
Metadata keys being write-once is worth calling out. A reused key reverts rather than overwriting, which
matches Fabric’s StateMustNotExist. Silently overwriting something like an HTLC claim key would be a
correctness bug, not a convenience.
A revert emits no StateCommitted event. Since a recipient who only saw the token request knows the
anchor and nothing else, and finds the transaction by scanning for that event, log scanning can only
ever discover success. There is no failure event to find, because the failed transaction wrote nothing.
A recipient must therefore treat “no event by the timeout” as failure. This asymmetry is a consequence of identifying transactions by anchor rather than by transaction hash, and it is why the finality timeout is a correctness setting rather than a tuning knob.
The interfaces as actually built, in
contracts/src:
interface ITokenState {
// delta is a typed struct, not opaque bytes, so the contract recomputes the EIP-712 hash itself.
function applyStateDelta(StateDelta calldata delta, bytes[] calldata signatures) external returns (bool);
function getToken(bytes32 tokenID) external view returns (bytes memory);
// isSpent/areTokensSpent revert with UnsupportedForGraphHiding on a graph-hiding clone: spend
// state there is keyed by serial number, not token ID. Use isSerialUsed instead.
function isSpent(bytes32 tokenID) external view returns (bool);
function areTokensSpent(bytes32[] calldata tokenIDs) external view returns (bool[] memory);
function isSerialUsed(bytes32 serial) external view returns (bool);
function getPublicParameters() external view returns (bytes memory);
function getPublicParamsVersion() external view returns (uint64);
function getPublicParamsHash() external view returns (bytes32);
function getTransferMetadata(bytes32 key) external view returns (bytes memory);
function getTokenRequestHash(bytes32 anchor) external view returns (bytes32);
event StateCommitted(bytes32 indexed anchor, bool success, string message);
event PublicParametersUpdated(bytes32 indexed paramsHash, uint64 version);
}
interface IEndorsementVerifier {
function verify(bytes32 digest, bytes[] calldata signatures) external view returns (bool);
function isEndorser(address a) external view returns (bool);
function getEndorsers() external view returns (address[] memory);
function getThreshold() external view returns (uint256);
}
There is deliberately no setPublicParameters(address admin): parameters update through an endorsed
setup delta, gated by the same threshold as any other state change (see “Phase 2” above). There is also
no addEndorser/removeEndorser/setThreshold in v1 — the endorser set and threshold are fixed at
construction. Runtime endorser-set changes are a quorum-gated feature for a later version, not a
deployer privilege; none of the v1 flows (issue, transfer, redeem, parameters update) need to mutate the
endorser set.
The contract side above is the small, cheap half of the system. The Go side does the actual work: by the
time a transaction reaches the chain, a quorum has already validated it off chain. This is the
responder/initiator pair in evm/endorsement, the same shape as the Fabric and FabricX endorsement
views.
The responder runs on every node that endorses, once per request:
type EndorseRequest struct {
TokenRequest []byte
TMSID token.TMSID
Anchor string
Metadata map[string][]byte
}
type EndorseResponse struct {
Delta *statedelta.StateDelta
Signature []byte
EndorserAddress string
Err string
}
eth_call at finalized), using the same Token SDK
validator a Fabric deployment uses.StateDelta.The initiator does not build a StateDelta. Producing one means validating the request against
on-chain state, which is exactly the work already delegated to endorsers, so the initiator relays what
they signed rather than computing its own copy. For each reply it checks that the anchor matches what it
asked about, the delta’s structural invariants hold, and the signature recovers to a registered endorser
over that delta’s own digest, then groups signatures by delta. The quorum is the first delta to reach
threshold distinct signers; a reply that disagrees with the rest is set aside rather than treated as
fatal, so one node computing a different delta cannot break the transaction for everyone else.
An earlier version of the driver had the initiator independently rebuild the delta as a cross-check before comparing it to what came back. It came out during review, for two reasons: rebuilding bought no security (a quorum willing to sign something bad does not need the initiator’s agreement to apply it), and it cost liveness (an initiator that read public parameters on the other side of an update from its endorsers computed a different digest and discarded every valid signature as an unrecognized signer). What the initiator keeps is what it can check without running a validator: the anchor binding and the delta’s shape.
Once threshold is met, the initiator ABI-encodes applyStateDelta(delta, signatures), signs the
Ethereum transaction with the submitter’s key, and broadcasts it.
These are properties of the shipped Approach-2 driver that an operator has to know about. Bootstrapping steps live in the Ethereum Deployment Runbook.
Connect refuses to bind a TMS to a network whose configuration contradicts the chain, so a
misconfiguration surfaces at startup rather than as failing transactions later. It checks three things.
The first is the chain id: the node has to report the chain the driver is configured to sign for, otherwise every signature would be produced for a chain nobody is running.
The second is that contracts.tokenState actually has code deployed at it (eth_getCode, i.e. CodeAt).
This one exists because the alternative failure mode is silent: an eth_call against an address with no
code at all does not revert, it just returns empty data, so a typo’d or not-yet-deployed address would
otherwise look connected right up until the first real transaction.
The third is the endorsement policy. contracts.tokenState names the verifier it delegates signature
checking to, and the driver reads the threshold and endorser set back from it. The configured
endorsement.threshold has to equal the one the EndorsementVerifier was constructed with, and every
address in endorsement.endorsers has to be registered in it. Getting either wrong is expensive to
diagnose without this check, because it does not look like a configuration problem: the node collects
what it believes is a quorum, spends a full endorsement round trip doing it, and the contract then
rejects the bundle, which arrives as a revert. One stale endorser address is enough to fail every
transaction, because the initiator counts its reply toward the quorum and the contract rejects the whole
bundle as an unauthorized signer.
Only a contradiction the driver actually observed is fatal. If the verifier cannot be read at all — the
contract is not deployed yet, or the node is briefly unhappy — the check is logged and skipped rather
than refusing the connection. These reads use the latest block tag rather than the configured one:
the endorser set and threshold are immutable after the verifier is constructed, so there is nothing a
later block can change, and reading at finalized would leave a freshly deployed network unable to see
its own verifier for the whole finalization window.
A third check runs once, when the driver builds the network rather than per TMS: every TMS declaring the
same network name has to agree on endpoint and chainID. One network name means one JSON-RPC
connection, shared by every TMS on it, so a disagreement here is refused at startup rather than leaving
a second TMS silently talking to the first TMS’s chain.
More than one TMS can declare the same EVM network. There is no channel to separate them by, so the
network name is the only sharing key. Each TMS still keeps its own services.network.evm block in full:
its own contracts.tokenState (and therefore its own EndorsementVerifier and EIP-712 domain), its own
endorsement policy, submitter account and gas policy. Only the JSON-RPC connection itself
(endpoint, chainID) is actually shared, and the startup check above enforces that every TMS on the
network agrees on it.
A public-parameters watcher is started per distinct contracts.tokenState, not per network: two TMS with
different TokenState clones get independent watchers, so an update to one TMS’s contract is never applied
to another TMS’s public parameters. Two TMS that are deliberately configured to point at the very same
contracts.tokenState do share one watcher and see the same updates, which is the intended behaviour for
that setup.
This node’s own role as an endorser (endorser.enabled, endorser.keystore, endorser.address) is the
one thing that stays node-wide rather than per TMS, because FSC can register only one endorsement
responder per process (see registerEndorser’s doc comment in driver.go). If more than one TMS on the
network enables endorsing, they must all name the same key; the driver refuses to start otherwise.
The primary signal is the transaction receipt, polled alongside eth_getTransactionByHash: known but no
blockNumber yet means still pending; a receipt with status 1 means valid, status 0 means invalid; a
hash the node has never seen means dropped. This works against any JSON-RPC node, Besu included. A
fabric-x-evm gateway additionally exposes a pending → in-progress → committed | failed | superseded
lifecycle, which the driver can use as a faster signal where available. This is not a hypothetical
alternative: it ships with its own integration suites (integration-tests-evm-gateway,
integration-tests-evm-gateway-fabtoken), separate from the plain-node suites. The receipt path stays
primary because it works against any JSON-RPC node, gateway or not.
Reads happen at the PoS finalized block tag (about two epochs, roughly 13 minutes on Ethereum
mainnet), which takes reorg handling out of scope for v1.
Mapping onto the SDK’s status codes. The receipt’s own status integer and the SDK’s ValidationCode
are not the same numbering, which is easy to conflate since both use small integers: a receipt status of
1 maps to driver.Valid (1), and status 0 maps to driver.Invalid (2) — those two happen to line
up at 1 and diverge at 0/2. A transaction the node has seen but not yet mined maps to driver.Busy
(3); one the node has never seen maps to driver.Unknown (4), which escalates to driver.Invalid once
it has been absent past finality.timeout (see “Transaction recovery across restarts” below).
Two resolution paths, deliberately. getTokenRequestHash(anchor) is the cheap check for “is this
anchor committed at all”: one eth_call, no block range to search, works at any block tag. Recipient-side
resolution — scanning StateCommitted logs filtered by the indexed anchor — is the richer path, used when
the caller also needs the Ethereum transaction hash (every log carries it as node-supplied metadata), since
a contract has no way to read its own transaction hash and the driver never relies on having one from any
other source. Log scanning needs a block range, and log retention varies by node, so the common “is it
committed” case is built on the cheap call and does not depend on it.
This produces a real asymmetry, the same one noted above: a failed applyStateDelta reverts, so it never
emits StateCommitted, and log scanning by anchor can only ever discover success. A recipient must treat
“no event by the configured timeout” as failure, which makes finality.timeout a correctness setting,
not a tuning knob. It has to exceed both the chain’s real time-to-finality at the configured block tag
and the longest gap an application leaves between preparing a transaction and broadcasting it — otherwise
recovery condemns transactions that were merely slow to submit, not ones that were rejected.
A caller decides what to do with a failure by class, not by reading a message. The driver exposes the
distinction that matters — whether the chain has judged the transaction — as sentinel errors, matched with
errors.Is:
| Sentinel | Class | What it means | Recovery |
|---|---|---|---|
evm.ErrTransactionReverted |
permanent | the node executed the transaction and it reverted: a double spend, stale public parameters, a quorum the contract will not accept | re-derive the request against current state; do not resend |
evm.ErrNetworkUnavailable |
transient | the node could not be reached, timed out, or refused the transaction without executing it | retry with backoff; the request is untouched |
client.ErrExecutionReverted |
permanent | the JSON-RPC layer’s view of the same revert, before the driver wraps it | classified by the driver |
evm.ErrTransactionRejected / client.ErrTransactionRejected |
permanent | the node rejected the transaction outright (e.g. nonce too low, underpriced) rather than executing and reverting it | re-derive or re-sign as appropriate; do not blind-resend |
endorsement.ErrUnauthorized |
permanent | the caller is not on the endorsement allowlist | fix the allowlist; do not retry |
endorsement.ErrInsufficientEndorsements |
transient-ish | fewer distinct signers replied than the threshold | retry endorsement; check which endorsers were unreachable |
endorsement.ErrUnknownSigner |
permanent | a signature recovered to an address outside the configured endorser set | the driver’s endorser set has drifted from the contract’s |
endorsement.ErrDuplicateSigner |
permanent | the same endorser’s signature was counted twice | an initiator bug; N signatures from one endorser are not N endorsements |
endorsement.ErrDeltaMismatch / endorsement.ErrDivergentDeltas |
permanent | endorsers disagreed on the delta itself | usually a public-parameters version race between endorsement and read; re-endorse |
The split is load-bearing. Collapsing the two leaves a caller choosing between retrying a doomed transaction
forever and giving up on a working one. A revert is detected during eth_estimateGas, which executes the
transaction — so a rejected transaction is reported before any gas is paid for it.
Recognising a revert is node-specific, which is worth knowing when adding support for a new one. Besu
reports it as JSON-RPC code -32000, inside the range the spec reserves for implementations; geth and
anvil use EIP-1474’s code 3. The driver accepts either, and anything else in the reserved range, always
paired with revert appearing in the message — so a non-revert failure carrying one of those codes
(out of gas on 3, header not found on -32000) stays in the transient class. A node that reports a
revert some other way would have its transactions classified transient, and callers would retry them
forever, so that is the first thing to check against an unfamiliar client.
The ttx layer registers a finality listener in memory when it stores a transaction. A node that restarts
between storing a transaction and its finality would otherwise be left with a row stuck at Pending and
nothing that would ever move it: the chain has the answer and nobody is asking. Every later wait on that
transaction runs to its timeout, which looks like a finality bug and is not one.
The driver therefore starts the SDK’s recovery manager over both the transaction store and the audit store
when a namespace connects. It periodically re-asks the chain about transactions that have been Pending
longer than the configured TTL, using the same anchor lookup a fresh listener would have performed.
services.network.fabric.recovery — the same key the Fabric and FabricX drivers use,
not an EVM-specific one; a TMS with none uses the SDK defaults (enabled, 30s TTL, 5s scan interval).Absence still needs an answer. A failed applyStateDelta reverts and writes nothing, so an anchor the
recovery sweep asks about is either “not yet mined” or “was rejected” — both look identical as
driver.Unknown from a plain chain lookup, and the shared recovery handler treats Unknown as “ask again
next sweep” forever. The driver resolves this by wrapping the network in settledNetwork for the recovery
path only: an anchor still Unknown once its transaction row has been Pending longer than
finality.timeout is reported as driver.Invalid instead.
The age comes from the transaction store’s own Timestamp column, not from the recovery TTL or the sweep
schedule. Those answer two different questions: the TTL is “how soon is it worth asking again”, the
timeout is “how long before absence means rejection”. Raising the TTL to the finality timeout would seem
like the obvious shortcut, but it conflates them — it also stops recovery from confirming a transaction
that did commit until that same timeout has passed, which is exactly the case where the chain already had
the answer. Reading the row’s timestamp keeps the sweep frequent and the verdict patient, and it survives
the restart recovery exists to clean up after, which an in-memory timer would not.
Condemning on evidence, not just on elapsed time. finality.timeout alone can take many minutes to
resolve a permanently-rejected transfer, because a reverted applyStateDelta emits no event and, when it
reverts at gas-estimation time, no transaction is ever sent — so there is no receipt either, and Unknown
is the only answer the chain gives until the timeout elapses. Waiting that long is wasteful when the chain
already has proof the transaction can never apply: snSpent (contracts/src/TokenState.sol) only ever
moves from unspent to spent, never back, so if the anchor is absent and any of the transaction’s input
tokens is already spent by some other transaction, that other transaction is the one that consumed the
input and this one can never succeed. The verdict is permanent, not a confidence interval.
The recovery sweep checks this before falling back to the age gate: it parses the transaction’s stored
request into its input token ids and asks the chain, via AreTokensSpent, whether any of them are already
spent. This runs identically over the ttx store and the audit store, so the auditor — which never
observes the broadcaster’s synchronous Submit error — converges on driver.Invalid through the same
evidence, not only through its own multi-minute timeout.
Two things keep this from being either premature or racy:
finality.conflictGrace (default 30s) delays the verdict from the moment the conflict is first
observed, not from the transaction row’s own age. The evidence appears the instant the competing
transaction lands, which can be well before this transaction is even broadcast — a transaction that was
merely prepared and held is indistinguishable, at that instant, from one already rejected. The grace
window gives it a chance to either land (clearing the conflict) or genuinely fail before it is condemned.
While a conflict is pending but has not yet cleared the grace window, the transaction is reported as
Unknown even if its row is already older than finality.timeout — the age gate is deliberately not
allowed to race ahead and condemn it on an unrelated basis while the evidence path is still waiting out
the grace window.eth_calls against a moving block tag; if the transaction under suspicion itself applies in the block
between them, the first read says absent and the second says spent — spent by this very transaction.
Re-reading the anchor immediately before condemning closes that gap: the re-read runs at a block at least
as recent as the spent read, so if the transaction landed in between, it is reported committed instead of
being deleted as a transfer that just went final.A revert while checking spent status — expected under graph hiding, where areTokensSpent is unsupported
by design — is never treated as evidence; it degrades to the pre-existing age-gate behavior rather than
failing the sweep. The same is true of any store or chain read error along this path: an error is never
evidence, only ever “ask again next sweep”.
This condemns faster only through the recovery sweep. finality.Manager, which serves the synchronous
finality watch (AddFinalityListener), is deliberately left unchanged: it holds only the anchor and a
StateReader, with no store and no persisted state by design, so it has no input ids to check against.
A recipient waiting on the finality watch still resolves at finality.timeout; only the recovery path —
which does have a store to read the request from — condemns early.
Rebroadcasting a transaction whose earlier attempt was actually mined is a normal retry path — the caller’s
reply went missing, not the transaction. Broadcast treats this case as success rather than as a failure:
if the contract rejects a resubmission with AnchorAlreadyProcessed and the anchor is confirmed on chain,
the driver reports the transaction as committed rather than surfacing the rejection. The rejection really is
about the second attempt; the transfer itself already succeeded, and answering anything else would have the
caller discard a transaction that is final.
Every node that broadcasts needs its own Ethereum account. Nonces are per account and each node’s nonce
manager counts locally, so two nodes sharing an account will reuse a consumed nonce and get nonce too low.
Anything else spending from that account — an administrative parameters update, for instance — moves the
nodes’ nonces behind their backs in the same way. Keep a separate operator account for deployment and
administrative submissions.
The driver speaks plain JSON-RPC and does not link go-ethereum, so it works against any EVM node. Two things are worth checking against a new backend:
eth_maxPriorityFeePerGas is a client extension, not part of the specification. Besu does not implement
it; the driver asks first and falls back to deriving the tip from the part of eth_gasPrice above the base
fee. The fallback triggers only on method not found — a node that supports neither is an error, since a
zero tip produces transactions that never mine.-32000) is implementation-defined and shared with other server-side
failures. The driver pairs the code with the message rather than trusting either alone.Contracts must be compiled for the EVM version the target node supports. Besu’s dev network has no PUSH0, so
contracts build for paris; a shanghai build reverts every contract creation.
Choose Approach 1 (Smart Contract Validation) when:
Choose Approach 2 (Pre-Order Execution) when: