Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 295 additions & 5 deletions docs/services/network-ethereum.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,12 +323,12 @@ sequenceDiagram

Note over Driver,State: On-Chain Execution Phase
Driver->>Node: eth_sendRawTransaction
Node->>Contract: Execute applyStateUpdate()
Contract->>Contract: Verify endorser signatures
Contract->>Contract: Check signature threshold
Contract->>Contract: Double Spending Check
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 StateUpdateEvent
Contract->>Contract: Emit StateCommitted

Node->>Node: Include in block
Node->>Node: Mine block
Expand All @@ -337,8 +337,298 @@ sequenceDiagram
Driver-->>App: OnStatus(VALID)
```

### How the contracts interact

This section describes the contracts as built. Every name, error and check order below comes from
[`contracts/src`](../../x/token/services/network/evm/contracts/src); the conceptual sketch further down
the page predates them.

#### Where the work happens

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.

```mermaid
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?

#### The StateDelta

Everything below revolves around this struct, so it is worth reading once. It is defined in
[`StateDelta.sol`](../../x/token/services/network/evm/contracts/src/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.

#### The six files, and what each one is

`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.

```mermaid
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.

#### Phase 1, deployment

Run once per network, from
[`Deploy.s.sol`](../../x/token/services/network/evm/contracts/script/Deploy.s.sol). Three contracts are
created, and then the factory produces the clone that a TMS will actually use.

```mermaid
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](https://eips.ethereum.org/EIPS/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.

#### Phase 2, processing a transaction

Three addresses take part, but only one of them holds any state.

```mermaid
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
`delegatecall`s 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.

#### The same transaction, step by step

```mermaid
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.

#### The checks, in order

`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.

```mermaid
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.

#### What each failure means

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 |

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.

#### Why a failure is invisible to a recipient

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.

### Smart Contract Interface

The sketch below predates the implementation and is kept for the shape of the idea. The contracts that
were actually built live in
[`contracts/src`](../../x/token/services/network/evm/contracts/src): the entry point is
`TokenState.applyStateDelta(StateDelta, bytes[])`, described above.

```solidity
// Conceptual interface - not production code
interface ITokenContractWithEndorsement {
Expand Down
4 changes: 3 additions & 1 deletion x/token/services/network/evm/endorsement/fakes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ func (s *pipeSession) Close() {}
// own session via Session. Everything the endorsement views do not touch panics, so an unexpected
// dependency surfaces loudly rather than silently.
type fakeContext struct {
ctx context.Context
// view.Context is an interface whose Context() method returns one, so an implementation of it has
// to hold a context.
ctx context.Context //nolint:containedctx
me view.Identity
sessions map[string]view.Session // keyed by party UniqueID, for GetSession (initiator side)
own view.Session // for Session() (responder side)
Expand Down
4 changes: 3 additions & 1 deletion x/token/services/network/evm/endorsement/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ const getTokenMethod = "getToken(bytes32)" // #nosec G101 -- ABI method signatur
// Ledger satisfies token.Ledger, so it can be passed straight to
// Validator.UnmarshallAndVerifyWithMetadata.
type Ledger struct {
ctx context.Context
// GetState takes no context (driver.GetStateFnc has none), so the one to read the chain with is
// captured here. A Ledger is built per request and used only for it, so it stays short-lived.
ctx context.Context //nolint:containedctx
client client.EVMClient
tokenState client.Address
blockTag string
Expand Down
6 changes: 6 additions & 0 deletions x/token/services/network/evm/eth_network_driver_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ token/services/network/evm/

## 3. Smart Contracts

This section is the specification: storage layout, semantics, and the reasoning behind each decision. For
a reader meeting the contracts for the first time, the walkthrough in
[Network Service - Ethereum Implementation Guide](../../../../../docs/services/network-ethereum.md#how-the-contracts-interact)
covers the same ground with diagrams, a field-by-field tour of the `StateDelta`, and a table of every
revert and what causes it.

### 3.1 Storage layout — TokenState

```solidity
Expand Down
2 changes: 1 addition & 1 deletion x/token/services/network/evm/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/IBM/mathlib v0.3.0
github.com/LFDT-Panurus/panurus v0.0.0-00010101000000-000000000000
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
github.com/hyperledger-labs/fabric-smart-client v0.16.0
github.com/hyperledger-labs/fabric-smart-client v0.17.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.54.0
Expand Down
4 changes: 2 additions & 2 deletions x/token/services/network/evm/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hyperledger-labs/fabric-smart-client v0.16.0 h1:JZtM2pd174Wo3rOJJIEN7dgvjpsp+K2J2lqCifVulJc=
github.com/hyperledger-labs/fabric-smart-client v0.16.0/go.mod h1:Xfm18BI6WuGU3IG++j8AyiThC74ZBDjd+VI5zoH9edw=
github.com/hyperledger-labs/fabric-smart-client v0.17.0 h1:6BKrkd0PFuDM0GMjL5brVl+wF3pnxj+G390XNCbWjRo=
github.com/hyperledger-labs/fabric-smart-client v0.17.0/go.mod h1:Xfm18BI6WuGU3IG++j8AyiThC74ZBDjd+VI5zoH9edw=
github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2 h1:B1Nt8hKb//KvgGRprk0h1t4lCnwhE9/ryb1WqfZbV+M=
github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2/go.mod h1:X+DIyUsaTmalOpmpQfIvFZjKHQedrURQ5t4YqquX7lE=
github.com/hyperledger/fabric-lib-go v1.1.5-0.20260708100132-163bcc919208 h1:qA49XOMwyNPxggVyW+HSDAg7I3GdtlGWDTbh40/rwZk=
Expand Down
Loading