RandProofRandProof
Whitepaper

19. KeeperCoordinator.sol: The On-Chain Core

KeeperCoordinator.sol is implemented and tested as an MVP, deliberately scoped narrower than the full design described in the rest of this section, wi

19.0 Implementation Status

KeeperCoordinator.sol is implemented and tested as an MVP, deliberately scoped narrower than the full design described in the rest of this section, with every divergence disclosed here rather than left for a reader to discover by comparing code against spec. 26 passing tests cover the full request lifecycle (requestRandomness, fulfill, finalize, cancelRequest), keeper registration, and fee distribution, including 5 adversarial probes run before this was called safe (Section 29.16). This is the first contract in this product family where building the test suite itself surfaced a real cryptographic design correction -- not a testing inconvenience, but a genuine flaw in the originally specified signing scheme -- detailed in Section 19.2 below. KeeperCoordinator.sol is staged for Base Sepolia testnet deployment but is not yet integrated with EntropyAttestation.sol, LivenessGuard.sol, or RandProofAutomation.sol -- all three exist and are tested independently, none are wired into this contract's request-fulfillment flow yet. It has not had an external security audit.

19.1 Design Principles

KeeperCoordinator.sol is the immutable on-chain core. No admin key on fulfill(), the cryptographic trust anchor of the entire PoFR system.

19.1.1 Request Cancellation & Timeout

To prevent permanently locked funds when keepers fail to fulfill a request, the protocol implements a fulfillment timeout and cancellation mechanism:

uint256 public constant FULFILLMENT_TIMEOUT = 256; // blocks (~51 min on Base)
function cancelRequest(bytes32 eventId) external {
require(msg.sender == requesters[eventId], "Not original requester");
require(!isFulfilled[eventId], "Already fulfilled");
require(block.number >= requestBlock[eventId] + FULFILLMENT_TIMEOUT, "Timeout not reached");
uint256 refund = eventFees[eventId];
eventFees[eventId] = 0;
(bool ok, ) = msg.sender.call{value: refund}("");
require(ok, "Refund failed");
emit RequestCancelled(eventId, msg.sender, refund);
}

19.2 MVP Scope and Design Corrections

This subsection documents three deliberate divergences between the MVP implementation and the full design described elsewhere in this section, found and decided during the actual build rather than specified in advance.

19.2.1 Four Entropy Sources, Not Fifteen

The MVP implements 4 of the 15 entropy sources: block.timestamp and block.prevrandao (read directly on-chain), plus DRAND and NIST beacon values (fetched off-chain). These four were chosen because each one's correctness is independently verifiable, not because they were the easiest to build. The remaining 11 are deferred for concrete reasons: ANU QRNG and Tor Consensus already have a ready integration point in EntropyAttestation.sol (Section 9, AV-108) once this core loop is verified working; RandProofVRF, the Autonomous Beacon, and the Epoch Entropy Pool require keeper-network infrastructure (real threshold key generation, a VDF) that does not exist yet, and building that infrastructure before a real keeper network exists would add significant cryptographic complexity to produce a decentralization property the network does not have at Genesis scale regardless (AV-01 already discloses these three sources as effectively founder-controlled during Genesis); the remaining sources (Bitcoin Merkle Root, multi-chain block hashes, non-EVM chain-native VRF, BABE VRF, drand Quicknet) each require separate, real integration work independent of this contract's core design.

19.2.2 ECDSA Multisig, Not BLS Aggregate Signatures

This section's pseudocode (19.1, 19.3) and Section 6.4's lifecycle description both specify BLS aggregate signatures for keeper consensus. The MVP implementation uses t-of-n ECDSA multisig (ecrecover) instead, for two concrete reasons rather than convenience. First, the available Solidity libraries for BLS verification over BN254 are explicitly unaudited as of this writing, with at least one carrying a still-pending third-party audit; ecrecover is a native EVM precompile that has existed since Ethereum's genesis, not a third-party dependency. Second, at Genesis scale (t=3, n=10-12, Section 24.1), ECDSA multisig is also cheaper, not just safer -- BLS aggregation's real advantage, constant verification cost regardless of how many keepers sign, only pays for itself once the network is large enough that checking dozens of individual signatures would be expensive, which is not the case yet.

A related finding made during this same research: DRAND uses the BLS12-381 curve, not BN254. Ethereum mainnet gained native BLS12-381 precompiles (EIP-2537) in the May 2025 Pectra upgrade, but Base is an OP Stack L2, and L2s adopt L1 EIPs on their own separate schedule -- this whitepaper does not have confirmed evidence that Base has activated the equivalent precompile. Rather than build a dependency on an unconfirmed precompile, the MVP verifies DRAND the same way it verifies NIST: via multi-keeper agreement, not on-chain signature verification against DRAND's own key. This is a real, disclosed downgrade from the design described elsewhere in this section, not a silent one.

Keeper signature verification is isolated in a single internal function specifically so a future migration to real BLS aggregate signatures -- once precompile availability is confirmed, or a properly audited library exists -- only requires changing that one function's internals and parameter shape; the request lifecycle, fee distribution, and callback logic described in 19.1 and 19.3 do not need to change.

19.2.3 A Genuine Correction: block.prevrandao Cannot Be Pre-Signed

Building the MVP's test suite surfaced a real flaw in the signing scheme implied by Section 6.4's lifecycle description, where keepers compute M including the on-chain timestamp/VRF value before submitting a transaction. Direct testing confirmed that block.prevrandao is not predictable even one block in advance -- the same value, read immediately before and after a single block advanced on a local test network, was already different. This is the correct cryptographic property RANDAO is supposed to have, not a bug to design around by weakening it. It meant keepers could never agree in advance on a message that included a value which did not exist yet at signing time.

// CORRECTED signing scheme:
// Keepers sign only what they can know in advance:

signableMessage = keccak256(abi.encode(

chainId, address(this), eventId,

drandValue, nistValue, entropyBlock

));
// block.timestamp and block.prevrandao are read fresh,
// AFTER signature verification passes, and mixed in here:

seed = keccak256(abi.encode(

signableMessage, block.timestamp, block.prevrandao

));

This is not merely a workaround -- it is a genuine improvement over the originally specified scheme. Whichever keeper's transaction happens to land first now contributes a small amount of additional, unpredictable-until-mined entropy (exactly which block the fulfillment transaction landed in) on top of the keeper-agreed values, at no extra cost and with no prediction problem, since keepers never had to agree on or sign over these two values in the first place.

19.3 Immutable Fee Distribution

uint256 public constant KEEPER_SHARE = 70; // % to signing keepers
uint256 public constant PROTOCOL_SHARE = 20; // % to treasury
uint256 public constant GAS_RESERVE = 10; // % to gas pool
function _distributeFee(bytes32 eventId, address[] memory signers) internal {
uint256 fee = eventFees[eventId];
uint256 keeperPool = (fee * KEEPER_SHARE) / 100;
uint256 protocolCut = (fee * PROTOCOL_SHARE) / 100;
uint256 gasReserve = fee - keeperPool - protocolCut;
// Split proportional to stake weight
require(totalSignerStake > 0, "No active signer stake"); // S-02 guard
for (uint i = 0; i < signers.length; i++) {
// Hybrid model: 50% proportional to stake, 50% equal split. Max share capped at 2x equal-split (AV-28 fix)

pendingWithdrawals[signers[i]] += share; // pull-payment pattern

}
(bool ok, ) = treasury.call{value: protocolCut}(""); require(ok, "treasury transfer failed");
gasPool += gasReserve;
}
// Pull-payment withdrawal: keepers call withdraw() to claim accumulated earnings
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");

pendingWithdrawals[msg.sender] = 0; // Checks-Effects-Interactions

(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Withdrawal failed");
}
}

19.4 Off-Chain Keeper Reference Implementation

Before this writing, every contract in this product family assumed a keeper program existed and would submit real transactions against it; no such program had been written. A reference implementation now exists: a single Node.js script that listens for DrawRequest events, fetches real entropy, signs the corrected message described in Section 19.2.3, and submits fulfill() once enough signatures are held. Its two entropy-source endpoints were confirmed live and responding with the expected data shape by directly fetching them, not assumed from documentation alone: DRAND's public API at api.drand.sh/public/latest, and NIST's beacon API at beacon.nist.gov/beacon/2.0/pulse/last.

This reference implementation has one significant, disclosed gap: there is no real coordination layer between independently-run keeper processes. The script can fetch entropy and produce its own valid signature, but nothing in it transmits that signature to, or receives signatures from, a different keeper's process. Building a real gossip or message-relay layer for this is separate, undone work. The practical near-term workaround, appropriate for a solo operator bootstrapping Genesis-scale nodes, is to start with threshold set to 1 -- a single keeper's own signature already satisfies a threshold of 1, which proves the full request-to-callback loop end to end without requiring multi-process coordination -- before attempting the t=3 configuration the rest of this document targets for Genesis.

On this page