datahaven/operator
Steve Degosserie aa3409b239
feat(slashes): typed offence kinds, Perbill-to-WAD conversion, historical filtering, and liveness E2E test (#447)
## Summary

Introduces typed offence classification, a linear Perbill-to-WAD
conversion for EigenLayer slashing, historical offence filtering, and a
new E2E test proving end-to-end liveness detection through
`pallet_im_online`.

---

### OffenceKind enum

New `OffenceKind` enum classifies consensus offences:
- `LivenessOffence` — missed heartbeats (ImOnline)
- `BabeEquivocation` — double block production
- `GrandpaEquivocation` — double finality votes
- `BeefyEquivocation` — double BEEFY votes / fork voting / future block
voting
- `Custom(BoundedVec<u8, 256>)` — manual / governance slashes

Each variant carries a human-readable description string through the
Snowbridge message to EigenLayer's
`DatahavenServiceManager.slashValidatorsOperator()`.

### EquivocationReportWrapper

Generic wrapper around `ReportOffence` wired for BABE, GRANDPA, BEEFY,
and ImOnline in all three runtimes:

1. **Filters historical offences** — discards reports whose session
predates the bonding period, using `BondedEras` storage (analogous to
`FilterHistoricalOffences` in `pallet_staking`, but adapted to this
pallet's own era tracking).
2. **Tags offence kind** — stores the `OffenceKind` in
`PendingOffenceKind` double-map `(SessionIndex, ValidatorId)` before
delegating to `pallet_offences`. The `on_offence` handler reads it via
`take()` in the same block.
3. **Cleans up on failure** — removes stale `PendingOffenceKind` entries
if the inner reporter returns an error (e.g. duplicate report),
preventing them from leaking into unrelated future offences.

### Perbill to WAD conversion and MaxSlashWad

#### How Substrate computes slash fractions

Each offence type in Substrate defines its own
`slash_fraction(offenders_count)` returning a `Perbill`:

| Offence | Formula | Typical range |
|---|---|---|
| **BABE equivocation** | `min((3k/n)^2, 1)` | 1 offender / 100
validators: ~0.09%; 1/2: capped to 100% |
| **GRANDPA equivocation** | `min((3k/n)^2, 1)` | Same as BABE |
| **BEEFY double-vote** | `min((3k/n)^2, 1)` | Same as BABE/GRANDPA |
| **BEEFY fork/future voting** | Fixed `50%` | Always 50% |
| **ImOnline liveness** | `min(3*(k - floor(n/10) - 1)/n, 1) * 7%` | 10%
or fewer offline: **0%**; ~33% offline: ~5%; ~43% offline: 7% (max) |

Where `k` = number of concurrent offenders, `n` = validator set size.

**Key behavior for small validator sets (E2E):** With n=2, the ImOnline
threshold is `floor(2/10) + 1 = 1`. A single offender (`k=1`) fails
`checked_sub(1)` giving `Perbill(0)`. This means no `Slashes` storage
entry is created (since `compute_slash` returns `None` when the new
fraction doesn't exceed the prior slash), but the `SlashReported` event
is still emitted, proving the full detection pipeline works.

#### Linear conversion to EigenLayer WAD

The Substrate `Perbill` is linearly mapped to a WAD value capped by
`MaxSlashWad`:

```
WAD = perbill.deconstruct() * MaxSlashWad / 1_000_000_000
```

- `MaxSlashWad` default: **5e16** (= 5% in WAD format, where 1e18 =
100%)
- Governance-changeable dynamic runtime parameter (codec index 46)
- `Perbill(100%)` maps to exactly `MaxSlashWad` (the cap)
- `Perbill(0%)` maps to 0 (no slash sent to EigenLayer)

#### Concrete examples (with default MaxSlashWad = 5%)

| Scenario | Substrate Perbill | WAD sent to EigenLayer | EigenLayer % |
|---|---|---|---|
| BABE equivocation (1 of 100 validators) | ~0.09% | ~4.5e13 | ~0.0045%
|
| BABE equivocation (1 of 2 validators) | 100% (capped) | 5e16 | 5%
(max) |
| BEEFY fork voting | 50% | 2.5e16 | 2.5% |
| ImOnline liveness (1 of 2 offline) | 0% | 0 (no slash) | 0% |
| ImOnline liveness (~33% of large set offline) | ~5% | ~2.5e15 | ~0.25%
|
| Manual `force_inject_slash` at 20% | 20% | 1e16 | 1% |
| Manual `force_inject_slash` at 100% | 100% | 5e16 | 5% (max) |

The same WAD value is applied uniformly to all configured strategies via
the `SlashingRequest` struct sent through Snowbridge to
`DatahavenServiceManager.slashValidatorsOperator()`.

### E2E liveness slashing test

New test scenario (`should detect and slash an unresponsive validator`)
validates the full liveness detection pipeline:

1. Pauses bob's Docker container (preserving GRANDPA state via `docker
pause`)
2. Waits 200s (>= 2 full sessions) for `pallet_im_online` to detect
missed heartbeats
3. Unpauses bob to restore GRANDPA finality (2/2 validators needed)
4. Polls for `SlashReported` event (not `Slashes` storage — see slash
fraction note above)
5. Verifies the event confirms the full pipeline: `pallet_im_online ->
EquivocationReportWrapper -> pallet_offences -> on_offence`

The test uses `try/finally` to always unpause bob, `{ at: "best" }`
queries for non-finalized chain state during the pause, and drains prior
`SlashReported` events before starting.

### Tests

- **10 new unit tests**: `PendingOffenceKind` double-map semantics,
session isolation, wrapper historical filtering, error cleanup, WAD
conversion (100%, 50%, 0%), offence kind description propagation
- **New mock infrastructure**: `MockInnerReporter`, `MockOffence`,
`MockOkOutboundQueue` with slash data capture
- **E2E**: Updated `force_inject_slash` test to use `offence_kind` enum,
new liveness detection test

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Gonza Montiel <gonzamontiel@users.noreply.github.com>
Co-authored-by: undercover-cactus <lola@moonsonglabs.com>
2026-03-04 14:25:17 +01:00
..
benchmarking fix: add missing benchmarks and weights (#302) 2026-02-03 17:12:11 +01:00
node fix: 🐛 wire trusted_msps only when the provider is a BSP (#458) 2026-02-25 18:35:13 +02:00
pallets feat(slashes): typed offence kinds, Perbill-to-WAD conversion, historical filtering, and liveness E2E test (#447) 2026-03-04 14:25:17 +01:00
precompiles fix: 🐛 Align ProxyType enum in Proxy precompile with runtime (#413) 2026-01-26 21:33:58 +01:00
primitives fix: 🛡️ Check origin for validator set messages (#343) 2025-12-15 14:11:08 +01:00
runtime feat(slashes): typed offence kinds, Perbill-to-WAD conversion, historical filtering, and liveness E2E test (#447) 2026-03-04 14:25:17 +01:00
scripts feat: 🏁 pallet grandpa benchmarking (#442) 2026-03-03 09:20:04 +01:00
.dockerignore refactor: 🐳 Improve docker caching (again) (#86) 2025-05-27 16:14:15 +00:00
.gitignore test: Native token transfer e2e tests (#120) 2025-08-22 18:27:14 +02:00
Cargo.lock build: ⬆️ upgrade to StorageHub v0.4.3 (#468) 2026-03-04 11:56:06 +01:00
Cargo.toml build: ⬆️ upgrade to StorageHub v0.4.3 (#468) 2026-03-04 11:56:06 +01:00
DOCKER-COMPOSE.md feat: Add Docker Compose setup for local DataHaven network (#314) 2025-11-22 14:07:46 +01:00
docker-compose.yml feat: Add Docker Compose setup for local DataHaven network (#314) 2025-11-22 14:07:46 +01:00
Dockerfile feat: Add Docker Compose setup for local DataHaven network (#314) 2025-11-22 14:07:46 +01:00
README.md doc: Fix fast-runtime documentation (#311) 2025-11-19 17:23:10 +00:00
rust-toolchain.toml revert: ♻ Revert Rust toolchain to 1.88.0 (revert PR #362) (#392) 2026-01-14 08:37:27 +01:00

DataHaven Operator (Substrate Node) 🫎

The DataHaven operator is a Substrate-based blockchain node that serves as an EigenLayer AVS operator. It combines Substrate's modular framework with EVM compatibility (via Frontier) and cross-chain capabilities (via Snowbridge).

Overview

Built on the polkadot-sdk-solochain-template, this node implements:

  • EVM Compatibility: Full Ethereum compatibility via Frontier pallets
  • EigenLayer Integration: Operator registration and management via AVS contracts
  • External Validators: Dynamic validator set controlled by EigenLayer registry
  • Cross-chain Communication: Token and message passing via Snowbridge
  • Rewards System: Performance-based validator rewards from Ethereum

Project Structure

operator/
├── node/                          # Node implementation
│   ├── src/
│   │   ├── chain_spec.rs         # Chain specification & genesis config
│   │   ├── cli.rs                # CLI interface
│   │   ├── command.rs            # Command handlers
│   │   ├── rpc.rs                # RPC configuration
│   │   └── service.rs            # Node service setup
├── pallets/                       # Custom pallets
│   ├── external-validators/      # EigenLayer validator set management
│   ├── native-transfer/          # Cross-chain token transfers
│   └── rewards/                   # Validator rewards distribution
├── runtime/                       # Runtime configurations
│   ├── mainnet/                  # Mainnet runtime
│   ├── stagenet/                 # Stagenet runtime
│   └── testnet/                  # Testnet runtime (with fast-runtime feature)
└── scripts/                       # Utility scripts
    └── run-benchmarks.sh         # Runtime benchmarking automation

Prerequisites

Building

Development Build (Fast Runtime)

For local development with shorter epochs and eras:

cargo build --release --features fast-runtime

This switches runtime parameters to the fast variants (1-minute epochs, 3 sessions per era) while the block time remains 6 seconds.

Production Build

For production or stagenet deployments:

cargo build --release

Running Tests

# Run all tests
cargo test

# Run tests for specific pallet
cargo test -p pallet-external-validators

# Run with output
cargo test -- --nocapture

Code Quality

# Format code
cargo fmt

# Lint with clippy
cargo clippy --all-targets --all-features

Benchmarking

DataHaven uses runtime benchmarking to generate accurate weight calculations for all pallets. The benchmarking process is automated using frame-omni-bencher.

Requirements

  • Latest Rust stable version
  • frame-omni-bencher: Install with cargo install frame-omni-bencher --profile=production

Running Benchmarks

Execute from the operator directory:

# Benchmark all pallets for testnet runtime (default)
./scripts/run-benchmarks.sh

# Benchmark specific runtime
./scripts/run-benchmarks.sh mainnet

# Custom steps and repetitions
./scripts/run-benchmarks.sh testnet 100 50

The script will:

  1. Discover all available pallets
  2. Build runtime WASM with runtime-benchmarks feature
  3. Generate weight files in runtime/{runtime}/src/weights/
  4. Provide summary of results

Parameters:

  • runtime: Runtime to benchmark (testnet, stagenet, mainnet). Default: testnet
  • steps: Number of steps. Default: 50
  • repeat: Number of repetitions. Default: 20

Zombienet Testing

Zombienet provides local multi-validator network testing.

Setup

  1. Install Zombienet:

    # Download binary from releases
    # Or install via npm
    npm install -g @zombienet/cli
    
  2. Spawn local network with four validators:

    zombienet -p native spawn test/config/zombie-datahaven-local.toml
    

This launches a local solochain with BABE consensus for testing validator coordination.

Docker Image

Build local Docker image for testing:

cd ../test
bun build:docker:operator

This creates datahavenxyz/datahaven:local using optimized caching:

  • sccache: Rust build caching
  • cargo-chef: Dependency layer caching
  • BuildKit cache mounts: External cache restoration

Type Generation

After runtime changes, regenerate Polkadot-API TypeScript types:

cd ../test
bun generate:types           # Production runtime
bun generate:types:fast      # Fast runtime (development)

Integration Testing

For full network integration tests with Ethereum, Snowbridge, and contracts:

cd ../test
bun cli launch               # Interactive launcher
bun test:e2e                 # Run E2E test suite

See the test directory for comprehensive testing documentation.

Custom Pallets

External Validators

Manages the dynamic validator set based on EigenLayer operator registry. Syncs validator changes from Ethereum to the Substrate consensus layer.

Location: pallets/external-validators/

Native Transfer

Handles cross-chain token transfers between Ethereum and DataHaven via Snowbridge messaging.

Location: pallets/native-transfer/

Rewards

Distributes performance-based rewards to validators, processing reward messages from the Ethereum RewardsRegistry contract.

Location: pallets/rewards/

Each pallet includes its own tests and benchmarks. See pallet-specific README files for details.