Skip to content

Implement DKG Contracts#9

Open
jannikluhn wants to merge 17 commits into
mainfrom
dkg-contract
Open

Implement DKG Contracts#9
jannikluhn wants to merge 17 commits into
mainfrom
dkg-contract

Conversation

@jannikluhn
Copy link
Copy Markdown
Contributor

No description provided.

jannikluhn and others added 12 commits May 20, 2026 10:22
Introduces the DKGContract with its four immutables (PHASE_LENGTH,
DKG_LEAD_LENGTH, keyperSetManager, keyBroadcastContract) and a
matching deploy fixture. Phase arithmetic, bulletin-board functions,
and success voting follow in subsequent commits.
Adds Phase enum (None/Dealing/Accusing/Apologizing/Finalizing),
cycleLength(), dkgStart(keyperSetIndex, retryCounter), and
currentPhase(keyperSetIndex, retryCounter) to DKGContract.

DKG instance (k, r) starts at:
  activation_block(k) - DKG_LEAD_LENGTH + r * cycleLength()

All phase windows are derived arithmetically from that start; no
per-instance block is stored. dkgStart returns int256 so it can go
negative when lead exceeds the activation block (edge case handled
without reverting).

Tests cover all phase boundaries for r=0, the r=0→r=1 retry
boundary, dkgStart arithmetic, and the negative-start edge case.
Adds a standalone registry where Keypers register their ECIES
encryption public keys. Membership is verified via KeyperSetManager
at registration time; keys persist across Keyper Set transitions so
a new KeyperSet deployment does not require re-registration.

Key design decisions:
- Uses OZ EnumerableSet.AddressSet so off-chain services can backfill
  the full set of registered keys via getKeyperCount()/getKeyperAt().
- Re-registration (key update) is accepted: add() is idempotent so
  the set size does not grow on re-registration.
- KeyperSetNotFinalized is surfaced from KeyperSet when the set is
  not yet finalized.

Also updates Deploy.gnosh.s.sol and Deploy.s.sol to deploy
DKGContract and ECIESKeyRegistry alongside the existing contracts,
and wires DKGContract as the KeyperSet publisher in Deploy.s.sol.

Tests cover basic registration, key lookup, identity/membership
checks, arbitrary-bytes acceptance, and the iterable enumeration API.
Adds submitDealing (Dealing Phase), submitAccusation (Accusing Phase),
and submitApology (Apologizing Phase) to DKGContract. Each validates
phase, identity, and the succeeded[k] guard, then emits an event with
no on-chain cryptographic verification and no deduplication.

Key decisions:
- submitDealing accepts commitment and polyEval together; callers
  cannot submit them separately.
- Empty accusedIndices reverts with EmptyAccusation. An empty
  accusation has no protocol meaning; clients should skip the call.
- Empty apology arrays (length 0) are accepted as a valid no-op.
  Mismatched array lengths revert with MismatchedArrays.
- succeeded[k] mapping is declared here as a read-only gate for the
  bulletin-board functions; the write logic is owned by issue #4.
- Events use 3 indexed fields (keyperSetIndex, retryCounter,
  keyperIndex) for efficient off-chain filtering.
- A shared _checkMember helper calls
  KeyperSet(manager.getKeyperSetAddress(k)).getMember(keyperIndex)
  and requires msg.sender match — reverts NotKeyperAtIndex.

Tests cover phase/identity/array-validation for all three functions.
Implements submitSuccessVote(keyperSetIndex, retryCounter, keyperIndex,
eonPublicKey) on DKGContract. Accepts votes only during the Finalizing
phase, verifies caller membership, and prevents double-voting per
(keyperSetIndex, retryCounter, voter).

When a key hash reaches KeyperSet.getThreshold():
- succeeded[keyperSetIndex] = true (gates all message functions)
- KeyBroadcastContract.broadcastEonKey is invoked in try/catch so an
  already-broadcast key (or unauthorized publisher) does not block
  recording success.
- DKGSucceeded event is emitted regardless of the broadcast outcome.

Late-arriving votes within the same Finalizing window are accepted
and emit SuccessVoteSubmitted but do not re-trigger success once it
has already been recorded.

State added: voteCount[k][r][keyHash], hasVoted[k][r][voter].
Events added: SuccessVoteSubmitted, DKGSucceeded. Error: AlreadyVoted.

setUp now sets the DKGContract as keyperSet0's publisher before
finalization so the threshold-reached broadcast succeeds in the
default fixture. The silent-revert case constructs its own keyper set
with a different publisher.

Tests cover phase rejection, identity, double-vote, threshold counting
(per-key-hash isolation, no broadcast below threshold), silent
broadcast failure, post-success rejection across all message types,
concurrent keyper-set independence, and the r=0/r=1 retry boundary.
Replace the opaque ABI-encoded bytes blob with a native bytes[] so the Go
binding handles encoding and decoding without bespoke helpers.

Key decisions:
- Empty arrays are accepted for backward compatibility with the existing
  testConcurrentDKGInstancesAreIndependent / retry-dealing tests, which
  previously passed an empty bytes blob.

Files changed:
- src/common/DKGContract.sol: submitDealing argument and DealingSubmitted
  event now use bytes[] for polyEvals
- test/DKGContract.t.sol: all submitDealing call sites and the mirrored
  event signature updated to bytes[]
Add `dkgContract` slot, owner-only pre-finalization setter
`setDKGContract`, and `getDKGContract` view getter to KeyperSet.sol.
Expose `getDKGContract` on the IKeyperSet interface so the Go keyper
can read it through the typed binding when handling KeyperSetAdded.

Tests:
- forge test passes (116 tests, incl. new testDKGContract,
  testSetDKGContractOnlyOwner, and the after-finalized revert case)
- go build ./... passes for the regenerated keyperset binding

Files:
- src/common/KeyperSet.sol
- src/common/intf/IKeyperSet.sol
- test/KeyperSet.t.sol
- bindings/keyperset/keyperset.go (regenerated)

Notes for next iteration:
- E2E tests in mise-test-setup were not run: the foundry/anvil docker
  image is linux/amd64 and the sandbox is arm64, so the ethereum
  container fails with `exec format error`. Acceptance criteria of this
  task only require forge test + go build.
- Unblocks docs/dkg-module-refactor/contract-updates/003 (eons per-
  keyperset phase params), which will call GetDKGContract from Go and
  store phase_length/lead_length on the eons row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forge test contract `DKGBenchmark` that measures total on-chain gas for a
clean DKG run (n submitDealing calls + ⌈2n/3⌉ submitSuccessVote calls) at
n = 3, 5, 10, 20, 50. Setup gas — contract deployment and keyper set
creation — is excluded; only protocol operation cost is accumulated via
gasleft() deltas around each contract call.

Key decisions:
- Realistic, non-zero (0xAB) payload sizes from the PRD: commitment =
  4 + threshold × 96 bytes, each PolyEvals entry = 129 bytes (ECIES),
  EonPublicKey = 96 bytes. Non-zero bytes cost 16 gas/byte in calldata
  and match real BLS/ECIES data.
- Threshold via integer ⌈2n/3⌉ = (2n + 2) / 3.
- Fresh KeyperSetManager / KeyBroadcastContract / DKGContract per test
  so warm-storage artefacts from earlier runs do not contaminate
  results.
- Per-test console.log line emits scenario, n, threshold, and gas total
  for easy parsing.

Measured gas (monotonic by design — quadratic calldata dominates):
  n=3   →   293,144
  n=5   →   457,151
  n=10  →   901,165
  n=20  → 2,368,268
  n=50  → 10,962,537

Files changed:
- test/DKGBenchmark.t.sol (new)

Notes for next iteration:
- 002-worst-case-benchmark extends this with Accusing + Apologizing
  phases, accumulating into the same totalGas counter and adding five
  test_worstCase_n* entry points.
- Rolling-shutter e2e suite could not be exercised in this sandbox
  (postgres/sqlc downloads firewall-blocked); the change is purely
  additive Forge test code so it cannot regress Go paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor _runHappyPath into a parameterized _run(n, worstCase) and
extend it with accusing/apologizing phases for the worst-case scenario.
Add test_worstCase_n{3,5,10,20,50} entry points.

The worst case has every keyper accuse all n-1 others in one tx, then
respond to all n-1 accusers in one apology tx with plaintext 32-byte
BLS12-381 scalars (NOT 129-byte ECIES ciphertext — the accused keyper
reveals the eval in the clear).

Key decisions:
- Single _run function (parameterized) over duplicating happy-path code.
- Apology polyEvalData entries are 32 bytes per PRD: plaintext scalar.
- Accusing block = dealingBlock + PHASE_LENGTH;
  apologizing block = dealingBlock + 2 * PHASE_LENGTH;
  finalizing block unchanged.

Files: contracts/test/DKGBenchmark.t.sol.

Verified all 10 tests pass; worst-case gas strictly exceeds happy-path
at each n (n=50: 22M worst-case vs 11M happy-path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…AddKeyperSet

Service deployment now also deploys DKGContract and ECIESKeyRegistry, so the
e2e tests can exercise the full DKG cycle against the shutterservice keyper.
AddKeyperSet (both common and gnosh) accept an optional DKG_CONTRACT_ADDRESS
env var and call setDKGContract on the new KeyperSet when provided.

Key decisions:
- Reuse the existing deployDKGContract / deployECIESKeyRegistry helpers
  (already shared with Deploy.gnosh.s.sol) instead of forking. Reads the
  same DKG_PHASE_LENGTH / DKG_LEAD_LENGTH env defaults.
- AddKeyperSet treats DKG_CONTRACT_ADDRESS as optional: if unset, no
  setDKGContract call is made. This keeps backward compatibility with
  callers that haven't been updated, and lets the keyper's eons-row code
  fall back to its config-supplied global DKG contract.

Files changed:
- script/Deploy.service.s.sol: deploys DKGContract + ECIESKeyRegistry.
- script/AddKeyperSet.s.sol, script/AddKeyperSet.gnosh.s.sol: accept
  DKG_CONTRACT_ADDRESS env var.

Notes for next iteration:
- mise-test-setup's contracts container is built from
  https://github.com/shutter-network/contracts.git#docker so these script
  changes will only reach the e2e test runs once that branch is pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added `error WrongDKGContract` and `_checkDKGContract(uint64 keyperSetIndex)`
helper that reverts if the Keyper Set's `dkgContract` field does not match
`address(this)` (address(0) is treated as a mismatch — no special case).
Called before `_checkMember` in `submitDealing`, `submitAccusation`,
`submitApology`, and `submitSuccessVote`.

Files changed:
- src/common/DKGContract.sol: error + helper + 4 call sites
- test/DKGContract.t.sol: setUp wires setDKGContract; 5 new revert tests

Decision: check runs after phase/success guards so misconfigured keypers
get a clear WrongDKGContract error rather than a phase error.
All ten tests were reverting with WrongDKGContract(). _setup() finalized
the KeyperSet without calling ks.setDKGContract(address(dkg)), so
_checkDKGContract saw address(0) instead of address(this).

Added ks.setDKGContract(address(dkg)) between setPublisher and setFinalized,
mirroring DKGContractTest.t.sol.

Files changed: test/DKGBenchmark.t.sol
All ten DKGBenchmark tests reported inflated [PASS] (gas: X) figures
because _setup() (four contract deployments + keyper set creation) was
included in Forge's test-level gas counter. The PRD explicitly excludes
setup gas; totalGas accumulated via gasleft() was already correct but
the headline number was misleading.

Fixed by wrapping _setup(n) in vm.pauseGasMetering() /
vm.resumeGasMetering() in _run(). vm.pauseGasMetering() pauses Forge's
EVM gas counter without affecting gasleft(), so the per-call
gasleft()-difference accumulation into totalGas continues to work
correctly. The [PASS] gas numbers now reflect only protocol operations.

Key decisions:
- vm.pauseGasMetering() / vm.resumeGasMetering() is the canonical Forge
  approach for excluding scaffolding from per-test gas figures. No
  restructuring (e.g. splitting into per-N test contracts or moving
  _setup to Forge's setUp()) was needed.
- gasleft() is unaffected by pauseGasMetering, so the console.log
  totalGas output is unchanged and remains the authoritative measurement.

Before → after [PASS] gas (happy path):
  n=3:  5,007,283 → 427,541
  n=5:  5,347,205 → 672,400
  n=10: 6,195,906 → 1,283,443
  n=20: 8,489,712 → 3,101,929
  n=50: 19,548,247 → 12,734,473

Files changed: contracts/test/DKGBenchmark.t.sol

Notes: no e2e tests to run for a benchmark-only change; all ten
DKGBenchmark tests pass with forge test --match-contract DKGBenchmark.
jannikluhn and others added 4 commits May 27, 2026 16:26
Introduce IDKGContract covering DKGContract's full public surface (the
Phase enum, the seven external/public functions, and external-view
getters for every public state variable) so other contracts -- notably
KeyperSetManager in the next slice -- can call into a DKG contract
without importing the concrete type and re-creating the
KeyperSetManager <-> DKGContract import cycle.

Key decisions:
- The Phase enum moves from DKGContract to IDKGContract; DKGContract
  inherits it (references stay unqualified). DKGContract.t.sol now uses
  IDKGContract.Phase.
- keyperSetManager()/keyBroadcastContract() in the interface return
  address, matching this repo's existing intf/ convention (IKeyperSet,
  IKeyperSetManager return address, never concrete types) and the
  comparison the next slice needs (IDKGContract(dkg).keyperSetManager()
  != address(this)). Returning concrete types would force IDKGContract
  to import KeyperSetManager, reinstating the very cycle this interface
  exists to break. To satisfy the address-typed getters while keeping
  internal calls typed, the two contract-typed immutables are renamed to
  internal _keyperSetManager/_keyBroadcastContract with explicit address
  getters. ABI is unchanged (the auto-getters already returned address),
  so Go bindings are unaffected.

Files: src/common/intf/IDKGContract.sol (new),
src/common/DKGContract.sol, test/DKGContract.t.sol.

Verification: forge build clean (no new warnings vs baseline), forge
test 131/131 pass, ABI diff confirms the interface covers exactly
DKGContract's 14 public functions with matching return types.

Notes for next iteration: unblocks 002-validate-dkg-address-on-
registration. e2e suite (test-dkg-happy-path, test-dkg-offline-recovery)
not run -- mise absent and its bootstrap is blocked by the sandbox
classifier, as in every prior commit in this PRD area; this slice is
Solidity-only with no ABI change and is fully covered by the forge suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Enforce the registration-time complement to DKGContract's runtime
_checkDKGContract guard: a KeyperSet may only be registered if it
designates a DKG Contract that itself references this KeyperSetManager.
Without this, a KeyperSet paired with a zero or wrong DKG Contract
registers cleanly but can never run DKG (every submission reverts
WrongDKGContract), with no early signal to the DAO.

Changes (src):
- KeyperSetManager: two new errors DKGContractNotSet (zero DKG address)
  and DKGContractManagerMismatch (DKG's keyperSetManager != this). Both
  checks run after the existing isFinalized check, reading the DKG
  address via IKeyperSet.getDKGContract() and calling
  IDKGContract.keyperSetManager(). Imports IDKGContract (the interface,
  not the concrete DKGContract) so the KeyperSetManager <-> DKGContract
  cycle stays broken.

Changes (tests):
- KeyperSetManager.t.sol: setUp now pairs members0/members1 with a
  DKGContract bound to the test's manager; three new cases cover zero
  DKG, mismatched-manager DKG, and the matching happy path.
- DKGContract/ECIESKeyRegistry/EonKeyPublish/KeyBroadcastContract tests:
  their setUps registered bare KeyperSets, which now revert at
  registration; each now sets a correctly-paired DKGContract before
  finalization.

Key decisions:
- Errors declared at file scope on KeyperSetManager.sol (next to
  AlreadyDeactivated), matching the issue's "KeyperSetManager declares"
  wording; selectors are visible to tests via the existing import.
- DKGContract.t.sol's WrongDKGContract guard tests previously registered
  a KeyperSet with a zero DKG address. That state is now unreachable
  (addKeyperSet rejects it), so the helper points at a *second* valid
  DKGContract bound to the same manager (registration passes; runtime
  guard still fires). The now-impossible testSubmitDealingRevertsWhen
  DKGContractIsZero case is removed; the != comparison it exercised is
  fully covered by the remaining different-contract cases.

Verification: forge build clean (only pre-existing unsafe-typecast
lints in scripts/benchmarks; none from this change); forge test 133/133
pass (was 131: +3 KeyperSetManager cases, -1 obsolete zero-address case).

Blockers/notes: e2e suite (rolling-shutter/mise-test-setup/e2e-tests)
not run -- mise is absent and its mise.run bootstrap is blocked by the
sandbox classifier, as in every prior commit in this PRD area. This
change is Solidity-only, additive, with no addKeyperSet ABI signature
change and no runtime behavior change for correctly-configured keyper
sets, so the Go e2e happy-path/offline-recovery suites are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AddKeyperSet deployment script now reads the DKG contract address
with vm.envAddress("DKG_CONTRACT_ADDRESS") instead of the
vm.envOr(..., address(0)) fallback, and calls setDKGContract
unconditionally. This is the deployment-path complement to the
on-chain addKeyperSet validation added in ef231f8: a zero DKG address
would now revert DKGContractNotSet at the contract call, so failing
earlier at env-resolution time with Foundry's clear "environment
variable not found" message is strictly better than a silent
zero-address branch that always fails downstream.

Key decisions:
- Scope limited to AddKeyperSet.s.sol per the PRD/issue. The byte-
  identical AddKeyperSet.gnosh.s.sol is left untouched; it is not on any
  automated path (the e2e add-keyper-set mise-task always invokes
  AddKeyperSet.s.sol regardless of deployment type), so hardening it is
  a separate, out-of-scope follow-up.
- No new forge test: scripts are not unit-tested in this repo, and the
  new revert-on-missing-env behavior is Foundry framework semantics
  (vm.envAddress), not our logic.

Files changed:
- script/AddKeyperSet.s.sol

Verification: forge clean && forge build clean (only pre-existing
unsafe-typecast lints in scripts/benchmarks); forge test 133/133 pass.
The e2e add-keyper-set task already derives DKG_CONTRACT_ADDRESS from the
deployed DKGContract (both Deploy.service and Deploy.gnosh create one),
so the success path is preserved.

Notes: e2e suite (test-dkg-happy-path, test-dkg-offline-recovery) not
run -- mise is absent and its mise.run | sh bootstrap is blocked by the
sandbox classifier (Code-from-External), as in every prior commit in
this PRD area. The change is a one-line deployment-script edit with no
ABI/runtime impact on correctly-configured keyper sets, so the Go e2e
flows are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.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