EIP-3076 import/export, and why it matters during validator migration
There is a moment in every validator-client migration where you stop, look at the slashing-protection database, and ask yourself whether you actually trust what is in it. That database is the only thing standing between you and signing two attestations for the same target epoch. If it is incomplete, stale, or corrupted, you will eventually find out — and the way you find out is the kind that costs money.
EIP-3076 is the standard that makes those migrations survivable. It defines a JSON interchange format for slashing-protection state: a per-validator record of the highest source epoch ever signed, the highest target epoch ever signed, the highest block-proposal slot ever signed, and (optionally) the minimum bounds and a recent history of signed roots. Any validator client that supports it can export, and any signer that imports it can refuse to sign anything that would create a duplicate of what was already signed.
nklave imports EIP-3076 interchange files directly. This post walks through exactly what nklave import does, what database state results, what to verify before you cut traffic over, and the failure modes worth knowing about.
What the import actually does
The command is simple:
nklave import \
--interchange-file ./slashing-protection.json \
--keystore-dir ./keystores
Behind it, nklave parses the interchange JSON and, for each validator pubkey present, sets six columns in the slashing-protection database:
| Column | Source in interchange | Meaning |
|---|---|---|
attestation_min_source_epoch | signed_attestations[].source_epoch (min) | Lowest source ever signed |
attestation_min_target_epoch | signed_attestations[].target_epoch (min) | Lowest target ever signed |
attestation_max_source_epoch | signed_attestations[].source_epoch (max) | Highest source ever signed |
attestation_max_target_epoch | signed_attestations[].target_epoch (max) | Highest target ever signed |
proposal_min_slot | signed_blocks[].slot (min) | Lowest slot ever proposed |
proposal_max_slot | signed_blocks[].slot (max) | Highest slot ever proposed |
That is the entirety of the persistent state required to refuse the three slashable offenses: double-vote, surround-vote, double-block-proposal. Once these bounds are set, the policy engine has everything it needs to evaluate every subsequent signing request.
The import is idempotent. Re-running it with a more complete interchange file widens the bounds; it never narrows them. This matters: a partial export from a client that has been running for an hour does not erase the conservative bounds you set from a complete historical export. The “highest” stays highest. The “lowest” stays lowest.
Why the bounds matter, not the full history
Newcomers to EIP-3076 sometimes assume the interchange format must enumerate every signature ever produced. It does not, and it does not need to. The Casper FFG rules are bound-checks, not history-checks:
- Double-vote is refused if
target_epoch≤attestation_max_target_epoch. - Surround-vote is refused if
source_epoch<attestation_min_source_epochandtarget_epoch>attestation_max_target_epoch(or the mirror condition). - Double-block-proposal is refused if
slot≤proposal_max_slot.
Notice what is missing: any reference to which signing root was actually signed. The bounds are sufficient because the rules are. A signer that respects the bounds cannot produce a slashable signature, regardless of which specific historical signatures it has or has not seen.
This is also why a conservative import is safe even if your interchange file is incomplete: the bounds describe a region of the (source, target, slot) space inside which signing is forbidden. Importing a wider region than you actually need is safe — it makes the signer over-refuse, which is annoying but not slashable. Importing a narrower region than you need is unsafe — it makes the signer permit a slashable signature.
The asymmetry is the point. EIP-3076 import errs on the side of refusal.
What to verify before you cut over
Before you switch your validator client’s signer URL from the old client to nklave, run through a short verification list. None of it is exotic; all of it is the difference between a clean migration and a costly one.
1. Stop the old client before exporting. EIP-3076 exports are point-in-time. If the old client signs anything between the export and the cutover, that signature will not be in the interchange file, and nklave will not know about it. The safe sequence is:
1. Stop old validator client.
2. Export EIP-3076 interchange from the now-stopped client.
3. Import into nklave.
4. Start validator client pointed at nklave's signer URL.
If steps 2 and 3 take long enough that the next attestation slot lands during them, you will miss an attestation. That is acceptable. What is not acceptable is letting the old client keep signing during the export.
2. Validate the interchange file before importing. The interchange JSON has a schema. nklave validates it on import, but you should also eyeball it: confirm the network’s genesis validators root matches, confirm every pubkey you expect to see is present, confirm the bound values look sane (no target_epoch: 0 for a validator that has been attesting for a year).
3. Verify the resulting bounds. After import, query nklave for what it now knows about each validator:
nklave keys list --with-bounds
Cross-check against what the old client reported. If the highest target epoch for any validator is older than the network’s current epoch by more than a few, something is wrong with your export — most likely, you exported from a snapshot that was behind real-time.
4. Test with a refusal, not just an allowance. The single most reassuring thing you can do is request a signature that should be refused and verify nklave refuses it with the correct reason code. Send a signing request for an attestation at a target epoch below attestation_max_target_epoch. You should see refuse with reason double vote at target_epoch=N in the append-only log.
If you do not see that refusal, the import did not take. Stop. Do not start your validator client.
Failure modes worth knowing about
A few sharp edges, in order of how likely you are to hit them:
Genesis validators root mismatch. EIP-3076 exports include the network’s genesis_validators_root. If you export from a mainnet client and import into a testnet-configured nklave (or vice versa), the import will refuse — and rightly so. The bounds for the same pubkey on two different networks are not interchangeable.
Multiple sources of truth. If a validator was running under two clients in parallel (e.g. during an experiment), neither client’s export captures the union of what was signed. Exporting from both and importing both into nklave is fine — the import widens, never narrows. Exporting from one and ignoring the other is a slashing risk.
Mid-epoch exports. The interchange format does not capture whether a current-epoch attestation has already been transmitted to the beacon node but not yet observed on-chain. If you are exporting mid-epoch, the safest bound is the current epoch + 1, not the current epoch. nklave’s import does not enforce this — it trusts the file — so the conservative practice is to wait until the boundary before cutting over.
RocksDB vs Postgres backends. Single-host operators usually run the RocksDB backend; HA operators run a shared Postgres. Importing into RocksDB on one node when you intend to run HA gets you out of sync with the other node. Always import into the backend you will actually use in production. For Postgres HA, that means importing once, against the cluster — not once per node.
Export, too
Slashing-protection state is more mobile than it used to be. nklave can export back to EIP-3076 at any time:
nklave export \
--interchange-file ./out.json
This produces an interchange file that any conforming client or signer can import. The use cases worth keeping in mind: rolling back from nklave to a built-in slashing-protection DB, migrating between nklave deployments, and producing an auditable artifact at a known boundary (an upgrade, a key rotation, an annual review).
The export carries the bound columns and a sampled history. It is sufficient input for any other EIP-3076 importer to re-establish the same refusal envelope.
What the format does not solve
EIP-3076 is a state-transfer protocol, not a coordination protocol. It cannot prevent two signers from being live for the same validator at the same time. That is a deployment concern: the HA pattern with a shared Postgres slashing-protection DB serializes updates via row-level locks, which means even two nklave instances sharing the cluster cannot both sign the same attestation. Without that shared state — for example, two nklave instances each with their own RocksDB — the format will not save you. It is per-instance protection, lifted into transferability by being the same format everywhere.
If you remember one thing: EIP-3076 makes the validator client switchable. Keeping it accurate, conservative, and a single source of truth at any one moment is on you. The signer enforces what the file says; the file says what you exported. Get the export right, and migrations are calm.