diff --git a/contracts/src/core/BillingContract.sol b/contracts/src/core/BillingContract.sol new file mode 100644 index 000000000..b1ad6bc1c --- /dev/null +++ b/contracts/src/core/BillingContract.sol @@ -0,0 +1,762 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {WorldIDBase} from "./abstract/WorldIDBase.sol"; +import {IBillingContract} from "./interfaces/IBillingContract.sol"; + +/** + * @notice Minimal view of the OPRF key registry: the live node set the Billing Contract + * authenticates votes against. Both members are public state variables on the deployed + * `OprfKeyRegistry`, so their auto-generated getters satisfy this interface. + */ +interface IOprfNodeSet { + /// @notice The number of OPRF peers participating in key generation. + function numPeers() external view returns (uint16); + + /// @notice The OPRF peer address at `index` (0 <= index < numPeers()). + function peerAddresses(uint256 index) external view returns (address); +} + +/** + * @title BillingContract (World ID) + * @author World Contributors + * @notice World ID. On-chain billing for Uniqueness Proofs per WIP-107. + * @dev OPRF nodes count unique RP requests per epoch and submit EIP-712-signed vote chunks. The contract + * finalizes the lower-median count across the node set (gated on quorum), prices it through a + * tiered WLD fee schedule, accrues per-RP debt, and blocks RPs past their payment window. + * Finalization is permissionless and chunkable ({finalizeEpochs}), and must be driven by a third party. + * + * @custom:repo https://github.com/worldcoin/world-id-protocol + */ +contract BillingContract is WorldIDBase, IBillingContract { + using SafeERC20 for IERC20; + + //////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////// + + /// @dev One finalized epoch with outstanding debt. `uint32 + uint224` fills one storage slot. + struct UnpaidEpoch { + uint32 epoch; + uint224 amount; + } + + /// @dev All per-RP billing state. `periodIndex + periodCount` pack into one storage slot. + struct RpState { + // rebate-period index the running count belongs to. + uint32 periodIndex; + // requests already billed in that rebate period. + uint224 periodCount; + // cursor into `unpaidEpochs` for the oldest not-yet-paid epoch. + uint64 unpaidCursor; + // finalized epochs with non-zero debt, in ascending finalization order. + UnpaidEpoch[] unpaidEpochs; + } + + /// @dev Per-(epoch, signer) chunked-vote progress. Packed into one storage slot. + struct SubmitterState { + // next chunk index expected from the signer; non-zero iff the signer has submitted. + uint32 nextChunkIndex; + // last rpId accepted from the signer, enforcing global ordering across chunks. + uint64 lastChunkRpId; + // whether the signer already closed its chunked vote. + bool hasVoted; + } + + //////////////////////////////////////////////////////////// + // Members // + //////////////////////////////////////////////////////////// + + // DO NOT REORDER! To ensure compatibility between upgrades, it is exceedingly important + // that no reordering of these variables takes place. If reordering happens, a storage + // clash will occur (effectively a memory safety error). + + /// @dev OPRF key registry the live node set (n, peer addresses) is read from. + IOprfNodeSet internal _oprfKeyRegistry; + + /// @dev Append-only history of timing eras; the last entry is the current era. + TimingEra[] internal _timingEras; + + /// @dev Lowest epoch not yet finalized (global finalization cursor). + uint32 internal _nextEpochToFinalize; + + /// @dev Number of epochs in a rebate (volume-discount) period. + uint32 internal _rebatePeriodEpochs; + + /// @dev The single, current ordered tier schedule (no versioning). + Tier[] internal _tierSchedule; + + /// @dev rpId -> all per-RP billing state (rebate period, unpaid-epoch debt list and cursor). + mapping(uint64 => RpState) internal _rpState; + + // --- Transient per-epoch state (pruned when the epoch finalizes) --- + + /// @dev epoch -> signer -> chunked-vote progress (chunk cursor, rpId ordering, voted flag). + mapping(uint32 => mapping(address => SubmitterState)) internal _submitterState; + + /// @dev epoch -> all signers that submitted at least one chunk, completed or not. + mapping(uint32 => address[]) internal _epochSubmitters; + + /// @dev epoch -> signers whose chunked votes are complete and count toward quorum/medians. + mapping(uint32 => address[]) internal _epochVoters; + + /// @dev epoch -> rpIds that received at least one non-zero count and need finalization. + mapping(uint32 => uint64[]) internal _epochRpsToFinalize; + + /// @dev epoch -> rpId -> whether the rp is already in `_epochRpsToFinalize[epoch]`. + mapping(uint32 => mapping(uint64 => bool)) internal _epochRpsToFinalizeSet; + + /// @dev epoch -> signer -> rpId -> the non-zero count submitted by the signer for the rp. + mapping(uint32 => mapping(address => mapping(uint64 => uint64))) internal _epochVoteCounts; + + /// @dev Index into `_epochRpsToFinalize[_nextEpochToFinalize]` of the next RP to finalize, so a + /// single oversized epoch can be finalized across multiple (chunked) calls. + uint256 internal _finalizeRpCursor; + + //////////////////////////////////////////////////////////// + // Constants // + //////////////////////////////////////////////////////////// + + string public constant EIP712_NAME = "BillingContract"; + string public constant EIP712_VERSION = "1.0"; + + /// @inheritdoc IBillingContract + bytes32 public constant BILLING_VOTE_CHUNK_TYPEHASH = keccak256( + "BillingVoteChunk(uint32 epoch,uint32 chunkIndex,bool isFinal,RpCount[] counts)RpCount(uint64 rpId,uint64 count)" + ); + + /// @dev EIP-712 typehash for a single RpCount struct (member of a BillingVoteChunk). + bytes32 public constant RPCOUNT_TYPEHASH = keccak256("RpCount(uint64 rpId,uint64 count)"); + + //////////////////////////////////////////////////////////// + // Constructor // + //////////////////////////////////////////////////////////// + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + //////////////////////////////////////////////////////////// + // Initializer // + //////////////////////////////////////////////////////////// + + /** + * @notice Initializes the contract. + * @param feeRecipient The recipient of collected WLD fees. + * @param feeToken The WLD ERC-20 token used for fee payments. + * @param oprfKeyRegistry The OPRF key registry the node set is read from. + * @param genesis Unix timestamp of epoch 0's start. + * @param epochLength Epoch length in seconds. + * @param votingWindow Voting window in seconds (must be <= epochLength). + * @param paymentWindow Payment window in seconds. + * @param tiers Initial fee schedule (stored as version 0). + * @param rebatePeriodEpochs Number of epochs per rebate period. + */ + function initialize( + address feeRecipient, + address feeToken, + address oprfKeyRegistry, + uint64 genesis, + uint64 epochLength, + uint64 votingWindow, + uint64 paymentWindow, + Tier[] calldata tiers, + uint32 rebatePeriodEpochs + ) public virtual initializer { + if (oprfKeyRegistry == address(0) || feeToken == address(0) || feeRecipient == address(0)) { + revert ZeroAddress(); + } + + // registrationFee is unused (0); _feeToken (WLD) and _feeRecipient are reused for payments. + __BaseUpgradeable_init(EIP712_NAME, EIP712_VERSION, feeRecipient, feeToken, 0); + + _oprfKeyRegistry = IOprfNodeSet(oprfKeyRegistry); + // We require voting window to be less than epoch length to prevent overlapping voting windows. + if (epochLength == 0 || votingWindow == 0 || paymentWindow == 0 || votingWindow > epochLength) { + revert InvalidTiming(); + } + if (rebatePeriodEpochs == 0) revert InvalidTiming(); + + _timingEras.push( + TimingEra({ + startEpoch: 0, + startTime: genesis, + epochLength: epochLength, + votingWindow: votingWindow, + paymentWindow: paymentWindow + }) + ); + _rebatePeriodEpochs = rebatePeriodEpochs; + + _validateTiers(tiers); + _storeTierSchedule(tiers); + emit TierScheduleUpdated(); + } + + //////////////////////////////////////////////////////////// + // PUBLIC FUNCTIONS // + //////////////////////////////////////////////////////////// + + /// @inheritdoc IBillingContract + function submitBillingVotes(uint32 epoch, SignedVoteChunk[] calldata chunks) + external + virtual + onlyProxy + onlyInitialized + { + // The voting window for `epoch` must be currently open. + uint64 votingStart = epochEnd(epoch); + if (block.timestamp < votingStart) revert VotingWindowNotOpen(); + if (block.timestamp >= votingStart + _votingWindowEraOf(epoch).votingWindow) revert VotingWindowClosed(); + + uint256 chunkCount = chunks.length; + for (uint256 i = 0; i < chunkCount; i++) { + _recordVoteChunk(epoch, chunks[i]); + } + } + + /// @inheritdoc IBillingContract + function pay(RpPayment[] calldata payments) external virtual onlyProxy onlyInitialized { + // Settles currently-finalized debt only. RPs with no selected debt are skipped so a batched, permissionless + // call is not griefable by debt that was concurrently settled or never accrued. + uint256 len = payments.length; + for (uint256 i = 0; i < len; i++) { + uint64 rpId = payments[i].rpId; + uint32 uptoEpoch = payments[i].uptoEpoch; + uint256 amount = _clearDebtUpToEpoch(rpId, uptoEpoch); + if (amount == 0) continue; + if (amount > payments[i].maxAmount) revert DebtExceedsMax(); + + // Effects happen in {_clearDebtThrough}; pull tokens after state is updated. + _feeToken.safeTransferFrom(msg.sender, _feeRecipient, amount); + + emit DebtPaid(rpId, msg.sender, uptoEpoch, amount); + } + } + + /// @inheritdoc IBillingContract + function finalizeEpochs(uint32 uptoEpoch, uint256 maxSteps) external virtual onlyProxy onlyInitialized { + (bool exists, uint32 closed) = _latestClosedEpoch(); + if (!exists) return; + uint32 target = uptoEpoch < closed ? uptoEpoch : closed; + + uint32 e = _nextEpochToFinalize; + uint256 cursor = _finalizeRpCursor; + // Steps are either a single finalizeRp call or a pruneFinalizedEpoch call + uint256 steps = 0; + + while (e <= target && steps < maxSteps) { + uint64[] storage rps = _epochRpsToFinalize[e]; + uint256 len = rps.length; + + while (cursor < len && steps < maxSteps) { + _finalizeRp(e, rps[cursor]); + unchecked { + cursor++; + steps++; + } + } + // Only prune if we haven't exhausted the step budget + if (steps < maxSteps) { + _pruneFinalizedEpoch(e); + cursor = 0; + e++; + unchecked { + steps++; + } + } + } + + _nextEpochToFinalize = e; + _finalizeRpCursor = cursor; + } + + //////////////////////////////////////////////////////////// + // OWNER FUNCTIONS // + //////////////////////////////////////////////////////////// + + /// @inheritdoc IBillingContract + function setTierSchedule(Tier[] calldata tiers) external virtual onlyOwner onlyProxy onlyInitialized { + _validateTiers(tiers); + _storeTierSchedule(tiers); + emit TierScheduleUpdated(); + } + + /// @inheritdoc IBillingContract + function setTiming(uint64 epochLength, uint64 votingWindow, uint64 paymentWindow) + external + virtual + onlyOwner + onlyProxy + onlyInitialized + { + if (epochLength == 0 || votingWindow == 0 || paymentWindow == 0 || votingWindow > epochLength) { + revert InvalidTiming(); + } + + TimingEra storage current = _timingEras[_timingEras.length - 1]; + + // If the current era has not started yet (repeated change within one epoch, or a genesis + // that has not started), update its parameters in place. + if (block.timestamp < current.startTime) { + current.epochLength = epochLength; + current.votingWindow = votingWindow; + current.paymentWindow = paymentWindow; + emit TimingUpdated(epochLength, votingWindow, paymentWindow, current.startEpoch, current.startTime); + return; + } + + // Otherwise append a new era starting after the in-flight epoch. Existing epochs are + // never affected: their boundaries, voting windows, and payment deadlines stay defined + // by the eras they belong to; the new parameters govern later epochs only. + uint64 d = uint64(block.timestamp) - current.startTime; + uint256 eCur = uint256(current.startEpoch) + d / current.epochLength; // the in-flight epoch + if (eCur + 1 > type(uint32).max) revert EpochTooLarge(); + uint64 startTime = epochEnd(uint32(eCur)); + + _timingEras.push( + TimingEra({ + startEpoch: uint32(eCur) + 1, + startTime: startTime, + epochLength: epochLength, + votingWindow: votingWindow, + paymentWindow: paymentWindow + }) + ); + emit TimingUpdated(epochLength, votingWindow, paymentWindow, uint32(eCur) + 1, startTime); + } + + /// @inheritdoc IBillingContract + function updateOprfKeyRegistry(address newOprfKeyRegistry) external virtual onlyOwner onlyProxy onlyInitialized { + if (newOprfKeyRegistry == address(0)) revert ZeroAddress(); + address oldOprfKeyRegistry = address(_oprfKeyRegistry); + _oprfKeyRegistry = IOprfNodeSet(newOprfKeyRegistry); + emit OprfKeyRegistryUpdated(oldOprfKeyRegistry, newOprfKeyRegistry); + } + + /// @inheritdoc IBillingContract + function setRebatePeriodEpochs(uint32 rebatePeriodEpochs) external virtual onlyOwner onlyProxy onlyInitialized { + if (rebatePeriodEpochs == 0) revert InvalidTiming(); + _rebatePeriodEpochs = rebatePeriodEpochs; + emit RebatePeriodUpdated(rebatePeriodEpochs); + } + + //////////////////////////////////////////////////////////// + // VIEW FUNCTIONS // + //////////////////////////////////////////////////////////// + + /// @inheritdoc IBillingContract + function isBlocked(uint64 rpId) external view virtual onlyProxy onlyInitialized returns (bool) { + RpState storage state = _rpState[rpId]; + uint256 cursor = state.unpaidCursor; + UnpaidEpoch[] storage unpaid = state.unpaidEpochs; + // No unpaid epochs remain once the cursor reaches the end, so nothing can be overdue. + // This also guards the indexed read below against an out-of-bounds access. + if (cursor >= unpaid.length) return false; + return block.timestamp > _paymentDue(unpaid[cursor].epoch); + } + + /// @inheritdoc IBillingContract + function epochRequestCount(uint32 epoch, uint64 rpId) + external + view + virtual + onlyProxy + onlyInitialized + returns (uint64) + { + // Returns 0 for finalized (pruned) epochs, since their snapshot/counts are deleted. + return _median(epoch, rpId); + } + + /// @inheritdoc IBillingContract + function outstandingDebt(uint64 rpId) external view virtual onlyProxy onlyInitialized returns (uint256 amount) { + RpState storage state = _rpState[rpId]; + UnpaidEpoch[] storage unpaid = state.unpaidEpochs; + uint256 len = unpaid.length; + for (uint256 cursor = state.unpaidCursor; cursor < len; cursor++) { + amount += unpaid[cursor].amount; + } + } + + /// @inheritdoc IBillingContract + function epochWatermarks() + external + view + virtual + onlyProxy + onlyInitialized + returns (bool finalizedExists, uint32 finalizedEpoch, bool closedExists, uint32 closedEpoch) + { + uint32 next = _nextEpochToFinalize; + if (next != 0) { + finalizedExists = true; + finalizedEpoch = next - 1; + } + (closedExists, closedEpoch) = _latestClosedEpoch(); + } + + /// @inheritdoc IBillingContract + function DOMAIN_SEPARATOR() external view virtual onlyProxy onlyInitialized returns (bytes32) { + return _domainSeparatorV4(); + } + + /// @notice The timestamp at which epoch `epoch` ends (and its voting window opens). + /// @dev Defined for every epoch, past or future: the boundary derives from the era the epoch + /// belongs to, so timing changes never move it. + function epochEnd(uint32 epoch) public view virtual returns (uint64) { + TimingEra storage era = _eraOf(epoch); + uint256 end = uint256(era.startTime) + (uint256(epoch) + 1 - era.startEpoch) * era.epochLength; + if (end > type(uint64).max) revert EpochTooLarge(); + return uint64(end); + } + + /// @notice The timestamp at which epoch `epoch`'s voting window closes. + /// @dev Voting is open over `[epochEnd(epoch), votingWindowEnd(epoch))`. The window size is + /// governed by the era of epoch `epoch + 1` (see {_votingWindowEraOf}), so this stays exact + /// across timing changes rather than relying on the current era. + function votingWindowEnd(uint32 epoch) public view virtual returns (uint64) { + return epochEnd(epoch) + _votingWindowEraOf(epoch).votingWindow; + } + + /// @notice The timestamp at which epoch `epoch`'s payment window closes; payment is overdue after it. + /// @dev End of the voting window plus the payment window, both governed by the era of epoch `epoch + 1`. + function paymentWindowEnd(uint32 epoch) public view virtual returns (uint64) { + return _paymentDue(epoch); + } + + /// @notice The OPRF key registry address the node set is read from. + function getOprfKeyRegistry() external view virtual onlyProxy onlyInitialized returns (address) { + return address(_oprfKeyRegistry); + } + + /// @inheritdoc IBillingContract + function getTierSchedule() external view virtual onlyProxy onlyInitialized returns (Tier[] memory) { + return _tierSchedule; + } + + /// @inheritdoc IBillingContract + function getRebatePeriodEpochs() external view virtual onlyProxy onlyInitialized returns (uint32) { + return _rebatePeriodEpochs; + } + + /// @inheritdoc IBillingContract + function getTiming() + external + view + virtual + onlyProxy + onlyInitialized + returns ( + uint64 epochLength, + uint64 votingWindow, + uint64 paymentWindow, + uint32 eraStartEpoch, + uint64 eraStartTime + ) + { + TimingEra storage era = _timingEras[_timingEras.length - 1]; + return (era.epochLength, era.votingWindow, era.paymentWindow, era.startEpoch, era.startTime); + } + + /// @inheritdoc IBillingContract + function getEras() external view virtual onlyProxy onlyInitialized returns (TimingEra[] memory) { + return _timingEras; + } + + //////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + //////////////////////////////////////////////////////////// + + /// @dev The TimingEra governing epoch `epoch`: the newest era with `startEpoch <= epoch`. The walk + /// starts at the current era (the common case), and era 0 (`startEpoch == 0`) matches any + /// epoch, so the lookup always resolves. + function _eraOf(uint32 epoch) internal view returns (TimingEra storage) { + uint256 i = _timingEras.length - 1; + while (i > 0 && epoch < _timingEras[i].startEpoch) { + unchecked { + i--; + } + } + return _timingEras[i]; + } + + /// @dev The TimingEra an epoch's voting window (and payment window) is governed by. + // On the last epoch of an era, the size of the voting window is governed by the next era to prevent overlapping voting windows. + function _votingWindowEraOf(uint32 epoch) internal view returns (TimingEra storage) { + return _eraOf(epoch + 1); + } + + /// @dev The payment-due timestamp for `epoch`: end of its voting window plus the payment window from the voting window era. + function _paymentDue(uint32 epoch) internal view returns (uint64) { + TimingEra storage era = _votingWindowEraOf(epoch); + return epochEnd(epoch) + era.votingWindow + era.paymentWindow; + } + + /// @dev Clears `rp`'s finalized debt through `uptoEpoch`, then advances its unpaid-epoch cursor. + function _clearDebtUpToEpoch(uint64 rp, uint32 uptoEpoch) internal returns (uint256 amount) { + RpState storage state = _rpState[rp]; + UnpaidEpoch[] storage unpaid = state.unpaidEpochs; + uint256 cursor = state.unpaidCursor; + + while (cursor < unpaid.length) { + UnpaidEpoch storage entry = unpaid[cursor]; + uint32 epoch = entry.epoch; + if (epoch > uptoEpoch) break; + + amount += entry.amount; + delete unpaid[cursor]; + + unchecked { + cursor++; + } + } + + // The cursor advances monotonically and is never reset: paid slots are already zeroed + // above, so live storage stays bounded by the outstanding backlog without an O(n) array + // delete on the clearing payment. When fully paid, `cursor == unpaidEpochs.length`, + // which `isBlocked` and `outstandingDebt` treat as "no outstanding debt". + state.unpaidCursor = uint64(cursor); + } + + /// @dev The latest epoch whose voting window has fully closed, if any — exact across eras. + /// An era's window regime covers epochs `startEpoch - 1` through `nextEra.startEpoch - 2`, + /// closing at `startTime + k * epochLength + votingWindow`. The walk finds the newest era + /// whose first close has passed; the result is capped at the era's own regime, since the + /// next regime's first window may still be open. All windows of older regimes close by + /// their regime's end (`votingWindow <= epochLength`), so everything below is closed too. + function _latestClosedEpoch() internal view returns (bool exists, uint32 epoch) { + uint256 i = _timingEras.length; + while (i > 0) { + unchecked { + i--; + } + TimingEra storage era = _timingEras[i]; + uint256 firstClose = uint256(era.startTime) + era.votingWindow; + if (block.timestamp < firstClose) continue; // no window of this regime has closed yet + + uint256 k = (block.timestamp - firstClose) / era.epochLength; + // The largest closed epoch of this regime is `startEpoch + k - 1`. + uint256 latestPlusOne = uint256(era.startEpoch) + k; + if (i + 1 < _timingEras.length) { + uint256 regimeEndPlusOne = _timingEras[i + 1].startEpoch - 1; + if (latestPlusOne > regimeEndPlusOne) latestPlusOne = regimeEndPlusOne; + } + if (latestPlusOne == 0) return (false, 0); // fresh deployment: epoch 0 has not closed yet + if (latestPlusOne - 1 > type(uint32).max) revert EpochTooLarge(); + return (true, uint32(latestPlusOne - 1)); + } + return (false, 0); + } + + /// @dev The billing quorum for a node set of size `n`: a strict majority, floor(n/2)+1. + function _quorum(uint16 n) internal pure returns (uint16) { + return n / 2 + 1; + } + + /// @dev Whether `who` is a member of the live OPRF node set. + function _isNode(address who) internal view returns (bool) { + uint16 n = _oprfKeyRegistry.numPeers(); + for (uint256 i = 0; i < n; i++) { + if (_oprfKeyRegistry.peerAddresses(i) == who) return true; + } + return false; + } + + /// @dev Prunes epoch-level transient state after all queued RPs for `epoch` are finalized. + function _pruneFinalizedEpoch(uint32 epoch) internal { + address[] storage submitters = _epochSubmitters[epoch]; + uint256 submitterCount = submitters.length; + for (uint256 i = 0; i < submitterCount; i++) { + delete _submitterState[epoch][submitters[i]]; + } + delete _epochSubmitters[epoch]; + delete _epochVoters[epoch]; + delete _epochRpsToFinalize[epoch]; + } + + /// @dev Finalizes a single (epoch, rp): prices its lower-median count through the current + /// schedule, accrues debt, emits the audit event, and prunes the raw counts. + function _finalizeRp(uint32 epoch, uint64 rp) internal { + uint64 median_count = _median(epoch, rp); + // Prune counts for all submitters; deleting a missing rp count keeps default zero + address[] storage submitters = _epochSubmitters[epoch]; + uint256 submitterCount = submitters.length; + for (uint256 i = 0; i < submitterCount; i++) { + delete _epochVoteCounts[epoch][submitters[i]][rp]; + } + delete _epochRpsToFinalizeSet[epoch][rp]; // prune finalization queue membership + + if (median_count == 0) return; // below quorum or zero median nothing billed, no event + + uint256 base = _updatePeriodCountAndReturnBase(rp, epoch, median_count); + uint256 fee = _calculateTieredFee(base, median_count); + + if (fee > 0) { + if (fee > type(uint224).max) revert FeeOverflow(); + _rpState[rp].unpaidEpochs.push(UnpaidEpoch({epoch: epoch, amount: uint224(fee)})); + } + + emit EpochRpFinalized(epoch, rp, median_count, fee); + } + + /// @dev Updates an RP's running rebate-period count and returns the count before this epoch. + function _updatePeriodCountAndReturnBase(uint64 rp, uint32 epoch, uint64 epoch_count) + internal + returns (uint256 base) + { + // Check if the epoch is a new period boundary and reset the period count if so + uint32 periodIdx = epoch / _rebatePeriodEpochs; + RpState storage state = _rpState[rp]; + if (state.periodIndex != periodIdx) { + state.periodIndex = periodIdx; + state.periodCount = 0; + } + + // Return the previous period count + base = state.periodCount; + uint256 newPeriodCount = base + epoch_count; + if (newPeriodCount > type(uint224).max) revert PeriodStateOverflow(); + state.periodCount = uint224(newPeriodCount); + } + + /// @dev The lower median of an RP's submitted counts for `epoch`, with missing votes counted as + /// zero over the epoch's voters, gated on quorum. Returns 0 below quorum or for pruned/unvoted epochs. + function _median(uint32 epoch, uint64 rp) internal view returns (uint64) { + address[] storage voters = _epochVoters[epoch]; + uint256 voterCount = voters.length; + if (voterCount == 0) return 0; // no votes (or pruned) + + // Quorum is read live from the current node set. + uint16 n = _oprfKeyRegistry.numPeers(); + if (voterCount < _quorum(n)) return 0; + + uint64[] memory vals = new uint64[](voterCount); + for (uint256 i = 0; i < voterCount; i++) { + // Missing rp reports read as the mapping default zero and count in the median. + uint64 count = _epochVoteCounts[epoch][voters[i]][rp]; + + uint256 j = i; + while (j > 0 && count < vals[j - 1]) { + vals[j] = vals[j - 1]; + unchecked { + j--; + } + } + vals[j] = count; + } + + // Lower-median index over the v values (0-indexed). + return vals[(voterCount - 1) / 2]; + } + + /// @dev The marginal WLD fee for billing `count` additional requests on top of `base` already + /// billed this rebate period, using the current tier schedule (WIP-107 §6.4 tiered pricing). + function _calculateTieredFee(uint256 base, uint256 count) internal view returns (uint256 fee) { + Tier[] storage tiers = _tierSchedule; + uint256 from = base; + uint256 to = base + count; + uint256 len = tiers.length; + for (uint256 i = 0; i < len && from < to; i++) { + // Note: For the last tier, upTo is type(uint256).max + uint256 boundary = tiers[i].upTo; + if (boundary <= from) continue; // tier already fully consumed by `base` + uint256 segEnd = boundary < to ? boundary : to; + fee += (segEnd - from) * tiers[i].rate; + from = segEnd; + } + } + + /// @dev Validates, authenticates and records a single node's vote chunk for `epoch`. + function _recordVoteChunk(uint32 epoch, SignedVoteChunk calldata chunk) internal { + // Validate the counts (strictly ascending rpId, all non-zero) and build the EIP-712 + // hash of the RpCount[] array in a single pass + (bytes32 countsHash, uint64 lastRpId) = _validateAndHashCounts(chunk.counts); + // Authenticate by recovered signer, not msg.sender — any party may relay the votes + bytes32 digest = _hashTypedDataV4( + keccak256(abi.encode(BILLING_VOTE_CHUNK_TYPEHASH, epoch, chunk.chunkIndex, chunk.isFinal, countsHash)) + ); + address signer = ECDSA.recover(digest, chunk.signature); + + if (!_isNode(signer)) revert NotANode(); + + SubmitterState storage sub = _submitterState[epoch][signer]; + if (sub.hasVoted) revert VoteAlreadyClosed(); + + uint32 expectedChunkIndex = sub.nextChunkIndex; + if (chunk.chunkIndex != expectedChunkIndex) revert UnexpectedChunkIndex(); + // Check if RP id is ascending + if (chunk.counts.length != 0) { + uint64 firstRpId = chunk.counts[0].rpId; + if (firstRpId <= sub.lastChunkRpId) revert CountsNotAscending(); + sub.lastChunkRpId = lastRpId; + } + // A zero chunk cursor means this is the signer's first chunk for the epoch + if (expectedChunkIndex == 0) { + _epochSubmitters[epoch].push(signer); + } + sub.nextChunkIndex = expectedChunkIndex + 1; + // Update vote counts for each RP in the chunk + uint256 len = chunk.counts.length; + for (uint256 i = 0; i < len; i++) { + uint64 rpId = chunk.counts[i].rpId; + // Queue RP for finalization if this is the first non-zero count seen for this rp in the epoch + if (!_epochRpsToFinalizeSet[epoch][rpId]) { + _epochRpsToFinalizeSet[epoch][rpId] = true; + _epochRpsToFinalize[epoch].push(rpId); + } + _epochVoteCounts[epoch][signer][rpId] = chunk.counts[i].count; + } + // If this is the final chunk for the epoch, mark the signer as voted + if (chunk.isFinal) { + sub.hasVoted = true; + _epochVoters[epoch].push(signer); + } + } + + /// @dev Validates a vote's counts and returns the EIP-712 hash of the RpCount[] array. + /// rpIds must be strictly ascending (rejects duplicates and the invalid rpId 0); counts + /// must all be non-zero (zero counts are implicit and must be omitted). + function _validateAndHashCounts(RpCount[] calldata counts) internal pure returns (bytes32, uint64 lastRpId) { + uint256 len = counts.length; + bytes32[] memory hashes = new bytes32[](len); + uint64 prevRpId = 0; + for (uint256 i = 0; i < len; i++) { + uint64 rpId = counts[i].rpId; + uint64 count = counts[i].count; + if (count == 0) revert ZeroCount(); + if (rpId <= prevRpId) revert CountsNotAscending(); + prevRpId = rpId; + hashes[i] = keccak256(abi.encode(RPCOUNT_TYPEHASH, rpId, count)); + } + return (keccak256(abi.encodePacked(hashes)), prevRpId); + } + + /// @dev Validates a tier schedule: non-empty, strictly ascending `upTo` ending at max, + /// strictly decreasing rates. + function _validateTiers(Tier[] calldata tiers) internal pure { + uint256 len = tiers.length; + if (len == 0) revert InvalidTierSchedule(); + for (uint256 i = 0; i < len; i++) { + if (i > 0) { + // strictly ascending boundaries, strictly decreasing rates. + if (tiers[i].upTo <= tiers[i - 1].upTo) revert InvalidTierSchedule(); + if (tiers[i].rate >= tiers[i - 1].rate) revert InvalidTierSchedule(); + } + } + if (tiers[len - 1].upTo != type(uint256).max) revert InvalidTierSchedule(); + } + + /// @dev Replaces the current tier schedule with a validated one. + function _storeTierSchedule(Tier[] calldata tiers) internal { + delete _tierSchedule; // clear any existing schedule before repopulating + uint256 len = tiers.length; + for (uint256 i = 0; i < len; i++) { + _tierSchedule.push(tiers[i]); + } + } +} diff --git a/contracts/src/core/interfaces/IBillingContract.sol b/contracts/src/core/interfaces/IBillingContract.sol new file mode 100644 index 000000000..31cb8b21b --- /dev/null +++ b/contracts/src/core/interfaces/IBillingContract.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/** + * @title IBillingContract + * @author World Contributors + * @notice Interface for the World ID Billing Contract (WIP-107). + * @dev The Billing Contract settles per-epoch usage fees that a Relying Party (RP) pays to the + * protocol for Uniqueness Proofs. OPRF nodes count unique RP requests per epoch, EIP-712-sign + * their tally as one or more {BillingVoteChunk}s, and submit the chunks on-chain. The contract finalizes the + * lower-median count across the node set (gated on quorum), prices it through a tiered WLD fee + * schedule, and accrues per-RP debt. RPs whose oldest unpaid epoch is past its payment window + * are surfaced as blocked so OPRF nodes can refuse further service. + */ +interface IBillingContract { + //////////////////////////////////////////////////////////// + // STRUCTS // + //////////////////////////////////////////////////////////// + + /// @notice A single (rpId, count) entry inside a node's billing vote. + struct RpCount { + // the Relying Party the count is reported for. + uint64 rpId; + // the number of unique requests the node observed for the RP in the epoch. + uint64 count; + } + + /// @notice One chunk of an OPRF node's signed billing vote for a single epoch. + /// @dev The signed payload is the EIP-712 + /// `BillingVoteChunk(uint32 epoch,uint32 chunkIndex,bool isFinal,RpCount[] counts)` struct, + /// where `epoch` is supplied as the call argument shared by every chunk in the batch. + struct SignedVoteChunk { + // the zero-based chunk index for this node and epoch. Chunks must be submitted in order. + uint32 chunkIndex; + // true on the final chunk; the node counts as a voter only after this chunk is accepted. + bool isFinal; + // the per-RP counts reported by the node, strictly ascending globally across chunks, + // all counts non-zero. + RpCount[] counts; + // the node's EIP-712 signature over the chunk. The signer is recovered, not trusted from + // msg.sender, so any party may relay the chunks. + bytes signature; + } + + /// @notice A single RP's payment instruction. + struct RpPayment { + // the Relying Party whose finalized debt is being settled. + uint64 rpId; + // settle this RP's finalized debt up to and including this epoch. + uint32 uptoEpoch; + // slippage guard: revert if the selected debt exceeds this amount. + // use type(uint256).max for an unconditional payment. + uint256 maxAmount; + } + + /// @notice One timing era: the epoch parameters in force from `startEpoch` on. + /// @dev Eras are kept as an append-only history (one entry per timing change), so every epoch + /// that ever existed keeps its true boundaries, voting window, and payment deadline. + /// + /// An era's parameters govern its *timespan* `[startTime, nextEra.startTime)`: epoch + /// spans lying in it use its `epochLength`; voting windows *opening* in it use its + /// `votingWindow` and `paymentWindow`. Since votes for an epoch are cast after it ends, + /// the window of the last epoch before an era change opens exactly at the era boundary + /// and is thus governed by the new era, this keeps window closes monotone in epoch + /// number across changes (given `votingWindow <= epochLength`), which finalization + /// relies on. + /// + /// Example: era 0 = `{startEpoch: 0, startTime: 1000, len: 100, vote: 80, pay: 200}`, + /// and `setTiming(50, 40, 100)` is called at t=1250 (inside epoch 2), appending + /// era 1 = `{startEpoch: 3, startTime: 1300, len: 50, vote: 40, pay: 100}`: + /// + /// epoch 1 [1100, 1200) | window [1200, 1280) | due 1480 era-0 span, era-0 window + /// epoch 2 [1200, 1300) | window [1300, 1340) | due 1440 era-0 span, era-1 window + /// epoch 3 [1300, 1350) | window [1350, 1390) | due 1490 era-1 span, era-1 window + /// + /// Epoch 2 keeps its old length (its span lies in era 0), while its window — opening at + /// the boundary — uses the new parameters. Epoch 1's window, already open at the change, + /// is untouched and closes at its historic time. + struct TimingEra { + // the first epoch governed by this era's parameters. + uint32 startEpoch; + // the timestamp the era starts at: the end of epoch `startEpoch - 1` (genesis for era 0). + uint64 startTime; + // the epoch length in seconds. + uint64 epochLength; + // the voting window in seconds (<= epochLength). + uint64 votingWindow; + // the payment window in seconds. + uint64 paymentWindow; + } + + /// @notice A single tier in the marginal fee schedule. + /// @dev Tiers are ordered by ascending `upTo`; the final tier MUST use `type(uint256).max`. + /// Rates are strictly decreasing (volume discount). `rate` is WLD wei per request. + struct Tier { + // cumulative request boundary this tier covers up to (inclusive). + uint256 upTo; + // marginal price in WLD wei for each request that falls into this tier. + uint256 rate; + } + + //////////////////////////////////////////////////////////// + // ERRORS // + //////////////////////////////////////////////////////////// + + /// @dev Thrown when submitting votes for an epoch whose voting window has not opened yet. + error VotingWindowNotOpen(); + + /// @dev Thrown when submitting votes for an epoch whose voting window has already closed. + error VotingWindowClosed(); + + /// @dev Thrown when a recovered signer has already closed their chunked vote for the epoch. + error VoteAlreadyClosed(); + + /// @dev Thrown when a chunk does not match the signer's next expected chunk index. + error UnexpectedChunkIndex(); + + /// @dev Thrown when a recovered signer is not part of the live OPRF node set. + error NotANode(); + + /// @dev Thrown when a vote's counts are not strictly ascending by rpId (also rejects duplicates). + error CountsNotAscending(); + + /// @dev Thrown when a vote includes a zero count (zero counts must be omitted, not encoded). + error ZeroCount(); + + /// @dev Thrown when an RP's outstanding debt exceeds the caller-supplied `maxAmount`. + error DebtExceedsMax(); + + /// @dev Thrown when a submitted tier schedule is malformed. + error InvalidTierSchedule(); + + /// @dev Thrown when timing parameters violate the `votingWindow <= epochLength` invariant or are zero. + error InvalidTiming(); + + /// @dev Thrown when a timestamp-derived epoch exceeds the uint32 epoch domain, or an epoch's + /// end exceeds the uint64 timestamp domain. + error EpochTooLarge(); + + /// @dev Thrown when rebate-period accounting cannot fit in the packed period state. + error PeriodStateOverflow(); + + /// @dev Thrown when a finalized epoch fee cannot fit in the packed unpaid-epoch state. + error FeeOverflow(); + + //////////////////////////////////////////////////////////// + // EVENTS // + //////////////////////////////////////////////////////////// + + /// @notice Emitted for every RP that received a non-zero finalized count when an epoch finalizes. + /// @dev Raw per-epoch vote data is pruned on finalization, so this event is the canonical + /// off-chain record of finalized counts for audit/indexing. + /// @param epoch The finalized epoch. + /// @param rpId The Relying Party. + /// @param count The finalized (lower-median) request count. + /// @param fee The WLD fee accrued for the RP in this epoch. + event EpochRpFinalized(uint32 indexed epoch, uint64 indexed rpId, uint64 count, uint256 fee); + + /// @notice Emitted when an RP's outstanding debt is settled. + /// @param rpId The Relying Party. + /// @param payer The address that funded the payment. + /// @param uptoEpoch The highest epoch the payment attempted to settle. + /// @param amount The WLD amount transferred to the fee recipient. + event DebtPaid(uint64 indexed rpId, address indexed payer, uint32 uptoEpoch, uint256 amount); + + /// @notice Emitted when the tier schedule is replaced. + event TierScheduleUpdated(); + + /// @notice Emitted when the OPRF key registry address is updated. + /// @param oldOprfKeyRegistry The previous registry address. + /// @param newOprfKeyRegistry The new registry address. + event OprfKeyRegistryUpdated(address oldOprfKeyRegistry, address newOprfKeyRegistry); + + /// @notice Emitted when the epoch timing parameters are updated. + /// @dev OPRF nodes must re-derive their vote schedule from the new era: epoch boundaries from + /// `eraStartEpoch` on are `eraStartTime + (epoch + 1 - eraStartEpoch) * epochLength`. + /// Existing epochs are unaffected; their windows close at their original times. + /// @param epochLength The new epoch length in seconds. + /// @param votingWindow The new voting window in seconds. + /// @param paymentWindow The new payment window in seconds. + /// @param eraStartEpoch The first epoch governed by the new parameters. + /// @param eraStartTime The era start: end of epoch `eraStartEpoch - 1`, unchanged by the update. + event TimingUpdated( + uint64 epochLength, uint64 votingWindow, uint64 paymentWindow, uint32 eraStartEpoch, uint64 eraStartTime + ); + + /// @notice Emitted when the rebate (volume-discount) period length is updated. + /// @param rebatePeriodEpochs The new number of epochs per rebate period. + event RebatePeriodUpdated(uint32 rebatePeriodEpochs); + + //////////////////////////////////////////////////////////// + // PUBLIC FUNCTIONS // + //////////////////////////////////////////////////////////// + + /** + * @notice Submit one or more OPRF node billing vote chunks for a single epoch. + * @dev Records votes only; does not finalize as a side effect (finalization is driven solely by + * {finalizeEpochs}), so a node's vote gas never carries another epoch's finalization cost. + * Quorum and pricing are read live at finalization (no per-epoch snapshot). Authenticates + * by recovered signer, not msg.sender. A node's chunks must be submitted in order and the + * node counts toward quorum only after its final chunk is accepted. + * @param epoch The epoch the vote chunks are cast for; its voting window must be currently open. + * @param chunks The signed vote chunks to record. + */ + function submitBillingVotes(uint32 epoch, SignedVoteChunk[] calldata chunks) external; + + /** + * @notice Settle finalized debt for one or more RPs through caller-selected epochs. + * @dev Permissionless; settles currently-finalized debt and does not finalize as a side effect + * (call {finalizeEpochs} first if closed epochs must be reflected). All-or-nothing per + * instruction: pays the debt through `uptoEpoch` or reverts if it exceeds the RP's + * `maxAmount` guard. Pulls WLD from msg.sender to the fee recipient. + * @param payments The per-RP payment instructions. + */ + function pay(RpPayment[] calldata payments) external; + + /** + * @notice Permissionlessly finalize closed epochs up to (and including) `uptoEpoch`. + * @dev Flushes the tail when node votes have stopped, so debt and `isBlocked` stay current. + * Chunkable: advances the global cursor by at most `maxSteps` units (one per RP finalized, + * one per epoch closed), resuming mid-epoch across calls so a large epoch never bricks. + * @param uptoEpoch The highest epoch to finalize up to; capped at the latest closed epoch. + * @param maxSteps The maximum units of finalization work to perform in this call. + */ + function finalizeEpochs(uint32 uptoEpoch, uint256 maxSteps) external; + + /** + * @notice Replace the tier schedule. Applies to every not-yet-finalized epoch (no versioning). + * @dev Owner only. Rates must be strictly decreasing, `upTo` strictly ascending, last `upTo` + * equal to type(uint256).max. + * @param tiers The new tier schedule. + */ + function setTierSchedule(Tier[] calldata tiers) external; + + /** + * @notice Update the epoch timing parameters. + * @dev Owner only. Re-checks the `votingWindow <= epochLength` invariant. Starts a new era at + * the end of the in-flight epoch: the new parameters govern later epochs only, and no + * existing epoch is affected in any way — boundaries, open voting windows, and payment + * deadlines all keep their original, era-true values (the full era history is retained). + * If the previous change's era has not started yet, its parameters are updated in place + * instead. Closed-but-unfinalized epochs finalize normally; a change never delays them. + * @param epochLength The epoch length in seconds. + * @param votingWindow The voting window in seconds. + * @param paymentWindow The payment window in seconds. + */ + function setTiming(uint64 epochLength, uint64 votingWindow, uint64 paymentWindow) external; + + /** + * @notice Update the OPRF key registry the node set is read from. + * @dev Owner only. + * @param newOprfKeyRegistry The new OPRF key registry address. + */ + function updateOprfKeyRegistry(address newOprfKeyRegistry) external; + + /** + * @notice Update the rebate (volume-discount) period length. + * @dev Owner only. Changing the divisor shifts period boundaries, so the running per-RP period + * count resets on each RP's next finalization; coordinate the change at a period boundary. + * @param rebatePeriodEpochs The new number of epochs per rebate period (must be non-zero). + */ + function setRebatePeriodEpochs(uint32 rebatePeriodEpochs) external; + + //////////////////////////////////////////////////////////// + // VIEW FUNCTIONS // + //////////////////////////////////////////////////////////// + + /** + * @notice Whether an RP is blocked for non-payment. + * @dev O(1). True when the RP has outstanding finalized debt whose oldest unpaid epoch is past + * its payment window. Reflects finalized state only; tail epochs become billable after a + * `finalizeEpochs`/`pay`/`submitBillingVotes` call advances finalization. Deadlines are + * era-true and permanent: each epoch's payment deadline derives from the parameters of + * the era its voting window belongs to, so timing changes never move any existing + * deadline in either direction. + * @param rpId The Relying Party. + * @return Whether the RP is currently blocked. + */ + function isBlocked(uint64 rpId) external view returns (bool); + + /** + * @notice The lower-median request count for an RP in a retained (not-yet-finalized) epoch. + * @dev Finalized epochs are pruned and return 0; their counts are available via {EpochRpFinalized}. + * @param epoch The epoch. + * @param rpId The Relying Party. + * @return The lower-median count, or 0 if below quorum / pruned / unseen. + */ + function epochRequestCount(uint32 epoch, uint64 rpId) external view returns (uint64); + + /** + * @notice The current outstanding (finalized) debt for an RP in WLD wei. + * @param rpId The Relying Party. + * @return The outstanding debt. + */ + function outstandingDebt(uint64 rpId) external view returns (uint256); + + /** + * @notice The latest finalized and latest closed epoch watermarks, read together in one call. + * @dev Both watermarks are derived from the same block, so callers get a consistent snapshot + * (no read skew between two separate calls). Finalization work is pending whenever the + * finalized watermark trails the closed one (including the case where nothing is finalized + * yet but some epoch has closed): closed epochs are exactly the ones {finalizeEpochs} can + * finalize. Off-chain keepers poll this to decide whether {finalizeEpochs} needs to be + * called, instead of blind-firing transactions. + * @return finalizedExists Whether any epoch has been finalized yet. + * @return finalizedEpoch The latest finalized epoch; only meaningful when `finalizedExists` is true. + * @return closedExists Whether any epoch's voting window has closed yet. + * @return closedEpoch The latest closed epoch; only meaningful when `closedExists` is true. + */ + function epochWatermarks() + external + view + returns (bool finalizedExists, uint32 finalizedEpoch, bool closedExists, uint32 closedEpoch); + + /// @notice The EIP-712 domain separator. + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice The EIP-712 typehash for a billing vote chunk. + function BILLING_VOTE_CHUNK_TYPEHASH() external view returns (bytes32); + + /** + * @notice The current tier schedule. + * @return The ordered tier schedule. + */ + function getTierSchedule() external view returns (Tier[] memory); + + /** + * @notice The number of epochs in a rebate (volume-discount) period. + * @return The rebate period length in epochs. + */ + function getRebatePeriodEpochs() external view returns (uint32); + + /** + * @notice The current era's timing parameters and start. + * @dev Epoch boundaries from `eraStartEpoch` on are + * `eraStartTime + (epoch + 1 - eraStartEpoch) * epochLength`. + * @return epochLength The epoch length in seconds. + * @return votingWindow The voting window in seconds. + * @return paymentWindow The payment window in seconds. + * @return eraStartEpoch The first epoch governed by the current parameters. + * @return eraStartTime The era start: end of epoch `eraStartEpoch - 1`. + */ + function getTiming() + external + view + returns ( + uint64 epochLength, + uint64 votingWindow, + uint64 paymentWindow, + uint32 eraStartEpoch, + uint64 eraStartTime + ); + + /** + * @notice The full timing-era history, oldest first; the last entry is the current era. + * @return The eras. + */ + function getEras() external view returns (TimingEra[] memory); +} diff --git a/contracts/test/core/BillingContract.t.sol b/contracts/test/core/BillingContract.t.sol new file mode 100644 index 000000000..eeb42203a --- /dev/null +++ b/contracts/test/core/BillingContract.t.sol @@ -0,0 +1,1060 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {BillingContract} from "../../src/core/BillingContract.sol"; +import {IBillingContract} from "../../src/core/interfaces/IBillingContract.sol"; +import {WorldIDBase} from "../../src/core/abstract/WorldIDBase.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; + +/// @dev Minimal stand-in for the OprfKeyRegistry node set the BillingContract authenticates against. +contract OprfKeyRegistryMock { + address[] public peers; + + function setPeers(address[] memory p) external { + peers = p; + } + + function numPeers() external view returns (uint16) { + return uint16(peers.length); + } + + function peerAddresses(uint256 index) external view returns (address) { + return peers[index]; + } +} + +contract BillingContractTest is Test { + // EIP-712 typehashes (mirror the contract constants). + bytes32 internal constant BILLING_VOTE_CHUNK_TYPEHASH = keccak256( + "BillingVoteChunk(uint32 epoch,uint32 chunkIndex,bool isFinal,RpCount[] counts)RpCount(uint64 rpId,uint64 count)" + ); + bytes32 internal constant RPCOUNT_TYPEHASH = keccak256("RpCount(uint64 rpId,uint64 count)"); + + // Timing config. + uint64 internal constant GENESIS = 1_000_000; + uint64 internal constant EPOCH_LEN = 100; + uint64 internal constant VOTING = 100; + uint64 internal constant PAYMENT = 200; + uint32 internal constant REBATE = 10; + + BillingContract internal billing; + ERC20Mock internal feeToken; + OprfKeyRegistryMock internal oprf; + + address internal owner; + address internal feeRecipient; + address internal payer; + + // OPRF node keypairs. + uint256 internal pk1 = 0xA11CE; + uint256 internal pk2 = 0xB0B; + uint256 internal pk3 = 0xC0FFEE; + uint256 internal pk4 = 0xD00D; + uint256 internal pk5 = 0xE1F; + address internal node1; + address internal node2; + address internal node3; + address internal node4; + address internal node5; + // A key that is not part of the node set. + uint256 internal pkOutsider = 0xBAD; + + function setUp() public { + owner = address(this); + feeRecipient = vm.addr(0x9999); + payer = vm.addr(0x7777); + + node1 = vm.addr(pk1); + node2 = vm.addr(pk2); + node3 = vm.addr(pk3); + node4 = vm.addr(pk4); + node5 = vm.addr(pk5); + + feeToken = new ERC20Mock(); + oprf = new OprfKeyRegistryMock(); + _setPeers3(); + + billing = _deploy(GENESIS, EPOCH_LEN, VOTING, PAYMENT, _tiers(100, 10, 5), REBATE); + + vm.warp(GENESIS); // start at epoch 0's genesis + } + + //////////////////////////////////////////////////////////// + // Helpers // + //////////////////////////////////////////////////////////// + + function _deploy( + uint64 genesis, + uint64 epochLength, + uint64 votingWindow, + uint64 paymentWindow, + IBillingContract.Tier[] memory tiers, + uint32 rebate + ) internal returns (BillingContract) { + BillingContract impl = new BillingContract(); + bytes memory initData = + _initData(feeRecipient, genesis, epochLength, votingWindow, paymentWindow, tiers, rebate); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + return BillingContract(address(proxy)); + } + + function _initData( + address recipient, + uint64 genesis, + uint64 epochLength, + uint64 votingWindow, + uint64 paymentWindow, + IBillingContract.Tier[] memory tiers, + uint32 rebate + ) internal view returns (bytes memory) { + return abi.encodeWithSelector( + BillingContract.initialize.selector, + recipient, + address(feeToken), + address(oprf), + genesis, + epochLength, + votingWindow, + paymentWindow, + tiers, + rebate + ); + } + + function _tiers(uint256 upTo0, uint256 rate0, uint256 rate1) + internal + pure + returns (IBillingContract.Tier[] memory t) + { + t = new IBillingContract.Tier[](2); + t[0] = IBillingContract.Tier({upTo: upTo0, rate: rate0}); + t[1] = IBillingContract.Tier({upTo: type(uint256).max, rate: rate1}); + } + + function _setPeers3() internal { + address[] memory p = new address[](3); + p[0] = node1; + p[1] = node2; + p[2] = node3; + oprf.setPeers(p); + } + + function _counts(uint64[] memory rpIds, uint64[] memory cs) + internal + pure + returns (IBillingContract.RpCount[] memory c) + { + c = new IBillingContract.RpCount[](rpIds.length); + for (uint256 i = 0; i < rpIds.length; i++) { + c[i] = IBillingContract.RpCount({rpId: rpIds[i], count: cs[i]}); + } + } + + function _one(uint64 rpId, uint64 count) internal pure returns (IBillingContract.RpCount[] memory c) { + c = new IBillingContract.RpCount[](1); + c[0] = IBillingContract.RpCount({rpId: rpId, count: count}); + } + + function _sign(uint256 pk, uint32 epoch, IBillingContract.RpCount[] memory counts) + internal + view + returns (IBillingContract.SignedVoteChunk memory) + { + return _signChunk(pk, epoch, 0, true, counts); + } + + function _signChunk( + uint256 pk, + uint32 epoch, + uint32 chunkIndex, + bool isFinal, + IBillingContract.RpCount[] memory counts + ) internal view returns (IBillingContract.SignedVoteChunk memory) { + bytes32[] memory hashes = new bytes32[](counts.length); + for (uint256 i = 0; i < counts.length; i++) { + hashes[i] = keccak256(abi.encode(RPCOUNT_TYPEHASH, counts[i].rpId, counts[i].count)); + } + bytes32 countsHash = keccak256(abi.encodePacked(hashes)); + bytes32 structHash = keccak256(abi.encode(BILLING_VOTE_CHUNK_TYPEHASH, epoch, chunkIndex, isFinal, countsHash)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", billing.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return IBillingContract.SignedVoteChunk({ + chunkIndex: chunkIndex, isFinal: isFinal, counts: counts, signature: abi.encodePacked(r, s, v) + }); + } + + /// @dev Warp to the start of `epoch`'s voting window. + function _openVoting(uint32 epoch) internal { + vm.warp(billing.epochEnd(epoch)); + } + + /// @dev Warp to just after `epoch`'s voting window closes. + function _closeVoting(uint32 epoch) internal { + vm.warp(billing.epochEnd(epoch) + VOTING); + } + + /// @dev Submit a single-rp vote from each provided key for `epoch` (opens the window first). + function _voteSingle(uint32 epoch, uint64 rpId, uint64 count, uint256[] memory pks) internal { + _openVoting(epoch); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](pks.length); + for (uint256 i = 0; i < pks.length; i++) { + votes[i] = _sign(pks[i], epoch, _one(rpId, count)); + } + billing.submitBillingVotes(epoch, votes); + } + + function _pks2() internal view returns (uint256[] memory pks) { + pks = new uint256[](2); + pks[0] = pk1; + pks[1] = pk2; + } + + function _pks3() internal view returns (uint256[] memory pks) { + pks = new uint256[](3); + pks[0] = pk1; + pks[1] = pk2; + pks[2] = pk3; + } + + //////////////////////////////////////////////////////////// + // Initialization // + //////////////////////////////////////////////////////////// + + function test_Initialize_setsState() public view { + assertEq(billing.getOprfKeyRegistry(), address(oprf)); + assertEq(billing.getFeeRecipient(), feeRecipient); + assertEq(billing.getFeeToken(), address(feeToken)); + assertEq(billing.getRebatePeriodEpochs(), REBATE); + assertEq(billing.getTierSchedule().length, 2); + assertEq(billing.epochEnd(0), GENESIS + EPOCH_LEN); + } + + function test_Initialize_revertsZeroAddress() public { + BillingContract impl = new BillingContract(); + bytes memory d = _initData(address(0), GENESIS, EPOCH_LEN, VOTING, PAYMENT, _tiers(100, 10, 5), REBATE); + vm.expectRevert(WorldIDBase.ZeroAddress.selector); + new ERC1967Proxy(address(impl), d); + } + + function test_Initialize_revertsVotingWindowGtEpoch() public { + BillingContract impl = new BillingContract(); + bytes memory d = _initData(feeRecipient, GENESIS, EPOCH_LEN, EPOCH_LEN + 1, PAYMENT, _tiers(100, 10, 5), REBATE); + vm.expectRevert(IBillingContract.InvalidTiming.selector); + new ERC1967Proxy(address(impl), d); + } + + function test_Initialize_revertsZeroTiming() public { + BillingContract impl = new BillingContract(); + bytes memory d = _initData(feeRecipient, GENESIS, 0, VOTING, PAYMENT, _tiers(100, 10, 5), REBATE); + vm.expectRevert(IBillingContract.InvalidTiming.selector); + new ERC1967Proxy(address(impl), d); + } + + function test_Initialize_revertsZeroRebate() public { + BillingContract impl = new BillingContract(); + bytes memory d = _initData(feeRecipient, GENESIS, EPOCH_LEN, VOTING, PAYMENT, _tiers(100, 10, 5), 0); + vm.expectRevert(IBillingContract.InvalidTiming.selector); + new ERC1967Proxy(address(impl), d); + } + + //////////////////////////////////////////////////////////// + // Vote submission // + //////////////////////////////////////////////////////////// + + function test_SubmitVotes_happy() public { + _voteSingle(0, 1, 50, _pks2()); + + // Median visible while the epoch is retained (not yet finalized). + assertEq(billing.epochRequestCount(0, 1), 50); + } + + function test_SubmitVotes_chunkedVoteCompletesAcrossChunks() public { + _openVoting(0); + + uint64[] memory rpIds = new uint64[](2); + rpIds[0] = 1; + rpIds[1] = 2; + uint64[] memory counts = new uint64[](2); + counts[0] = 50; + counts[1] = 90; + + IBillingContract.SignedVoteChunk[] memory chunks = new IBillingContract.SignedVoteChunk[](3); + chunks[0] = _signChunk(pk1, 0, 0, false, _one(1, 50)); + chunks[1] = _signChunk(pk1, 0, 1, true, _one(2, 90)); + chunks[2] = _sign(pk2, 0, _counts(rpIds, counts)); + billing.submitBillingVotes(0, chunks); + + assertEq(billing.epochRequestCount(0, 1), 50); + assertEq(billing.epochRequestCount(0, 2), 90); + } + + function test_SubmitVotes_incompleteChunkedVoteDoesNotCountTowardQuorum() public { + _openVoting(0); + + IBillingContract.SignedVoteChunk[] memory chunks = new IBillingContract.SignedVoteChunk[](2); + chunks[0] = _signChunk(pk1, 0, 0, false, _one(1, 100)); + chunks[1] = _sign(pk2, 0, _one(1, 10)); + billing.submitBillingVotes(0, chunks); + + assertEq(billing.epochRequestCount(0, 1), 0, "only one completed voter"); + _closeVoting(0); + billing.finalizeEpochs(0, 100); + assertEq(billing.outstandingDebt(1), 0); + } + + function test_SubmitVotes_revertsUnexpectedChunkIndex() public { + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory chunks = new IBillingContract.SignedVoteChunk[](1); + chunks[0] = _signChunk(pk1, 0, 1, true, _one(1, 10)); + vm.expectRevert(IBillingContract.UnexpectedChunkIndex.selector); + billing.submitBillingVotes(0, chunks); + } + + function test_SubmitVotes_revertsCrossChunkCountsNotAscending() public { + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory first = new IBillingContract.SignedVoteChunk[](1); + first[0] = _signChunk(pk1, 0, 0, false, _one(2, 20)); + billing.submitBillingVotes(0, first); + + IBillingContract.SignedVoteChunk[] memory second = new IBillingContract.SignedVoteChunk[](1); + second[0] = _signChunk(pk1, 0, 1, true, _one(1, 10)); + vm.expectRevert(IBillingContract.CountsNotAscending.selector); + billing.submitBillingVotes(0, second); + } + + function test_SubmitVotes_revertsBeforeWindow() public { + // Before epoch 0's voting window opens (still inside the epoch). + vm.warp(billing.epochEnd(0) - 1); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pk1, 0, _one(1, 10)); + vm.expectRevert(IBillingContract.VotingWindowNotOpen.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsAfterWindow() public { + _closeVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pk1, 0, _one(1, 10)); + vm.expectRevert(IBillingContract.VotingWindowClosed.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsNotANode() public { + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pkOutsider, 0, _one(1, 10)); + vm.expectRevert(IBillingContract.NotANode.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsVoteAlreadyClosed() public { + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](2); + votes[0] = _sign(pk1, 0, _one(1, 10)); + votes[1] = _sign(pk1, 0, _one(1, 20)); + vm.expectRevert(IBillingContract.VoteAlreadyClosed.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsCountsNotAscending() public { + _openVoting(0); + uint64[] memory rpIds = new uint64[](2); + rpIds[0] = 2; + rpIds[1] = 1; // descending + uint64[] memory cs = new uint64[](2); + cs[0] = 10; + cs[1] = 20; + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pk1, 0, _counts(rpIds, cs)); + vm.expectRevert(IBillingContract.CountsNotAscending.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsZeroCount() public { + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pk1, 0, _one(1, 0)); + vm.expectRevert(IBillingContract.ZeroCount.selector); + billing.submitBillingVotes(0, votes); + } + + function test_SubmitVotes_revertsNoNodes() public { + // With no snapshot/NoNodesRegistered guard, an empty node set makes every signer fail the + // live NotANode check. + oprf.setPeers(new address[](0)); + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](1); + votes[0] = _sign(pk1, 0, _one(1, 10)); + vm.expectRevert(IBillingContract.NotANode.selector); + billing.submitBillingVotes(0, votes); + } + + /// @dev submit must NOT finalize as a side effect (keeper-only finalization). + function test_SubmitVotes_doesNotFinalize() public { + _voteSingle(0, 1, 50, _pks2()); + // Open epoch 1 and submit; epoch 0 is now closed but must remain unfinalized. + _voteSingle(1, 1, 50, _pks2()); + assertEq(billing.outstandingDebt(1), 0, "submit must not finalize epoch 0"); + // Still retained (not pruned). + assertEq(billing.epochRequestCount(0, 1), 50); + } + + //////////////////////////////////////////////////////////// + // Finalization + median // + //////////////////////////////////////////////////////////// + + function test_Finalize_happyDebtAndEvent() public { + _voteSingle(0, 1, 50, _pks2()); + _closeVoting(0); + + vm.expectEmit(true, true, false, true, address(billing)); + emit IBillingContract.EpochRpFinalized(0, 1, 50, 500); // 50 * 10 (tier 0) + billing.finalizeEpochs(0, 100); + + assertEq(billing.outstandingDebt(1), 500); + // Pruned after finalize. + assertEq(billing.epochRequestCount(0, 1), 0); + } + + function test_Finalize_belowQuorumZero() public { + // Only 1 of 3 nodes votes → below quorum (2) → median 0, no debt. + uint256[] memory pks = new uint256[](1); + pks[0] = pk1; + _voteSingle(0, 1, 50, pks); + _closeVoting(0); + billing.finalizeEpochs(0, 100); + assertEq(billing.outstandingDebt(1), 0); + } + + function test_Median_oddV() public { + // n=3, all 3 vote counts [40, 50, 60] → lower median = 50. + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](3); + votes[0] = _sign(pk1, 0, _one(1, 40)); + votes[1] = _sign(pk2, 0, _one(1, 60)); + votes[2] = _sign(pk3, 0, _one(1, 50)); + billing.submitBillingVotes(0, votes); + assertEq(billing.epochRequestCount(0, 1), 50); + } + + function test_Median_evenV() public { + // n=4, all 4 vote counts [40,50,60,70] → lower median index (4-1)/2 = 1 → 50. + address[] memory p = new address[](4); + p[0] = node1; + p[1] = node2; + p[2] = node3; + p[3] = node4; + oprf.setPeers(p); + + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](4); + votes[0] = _sign(pk1, 0, _one(1, 70)); + votes[1] = _sign(pk2, 0, _one(1, 40)); + votes[2] = _sign(pk3, 0, _one(1, 60)); + votes[3] = _sign(pk4, 0, _one(1, 50)); + billing.submitBillingVotes(0, votes); + assertEq(billing.epochRequestCount(0, 1), 50); + } + + function test_Median_zeroFill() public { + // n=3, all 3 vote but only 2 report rp 1 (counts 5, 9); third omits → 0. + // Full sorted [0,5,9], lower-median idx (3-1)/2=1 → 5. + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](3); + votes[0] = _sign(pk1, 0, _one(1, 5)); + votes[1] = _sign(pk2, 0, _one(1, 9)); + votes[2] = _sign(pk3, 0, _one(2, 7)); // reports a different rp, so rp 1 gets a zero + billing.submitBillingVotes(0, votes); + assertEq(billing.epochRequestCount(0, 1), 5); + } + + function test_Median_zeroFillBelowMedianReturnsZero() public { + // n=5, all 5 vote but only 2 report rp 1 → 3 zeros. idx (5-1)/2=2 < zeros(3) → 0. + address[] memory p = new address[](5); + p[0] = node1; + p[1] = node2; + p[2] = node3; + p[3] = node4; + p[4] = node5; + oprf.setPeers(p); + + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](5); + votes[0] = _sign(pk1, 0, _one(1, 5)); + votes[1] = _sign(pk2, 0, _one(1, 9)); + votes[2] = _sign(pk3, 0, _one(2, 7)); + votes[3] = _sign(pk4, 0, _one(2, 7)); + votes[4] = _sign(pk5, 0, _one(2, 7)); + billing.submitBillingVotes(0, votes); + assertEq(billing.epochRequestCount(0, 1), 0); + } + + function test_Finalize_chunkedResumesMidEpoch() public { + // 2 voters report rps 1,2,3 each count 10 (quorum met). rpList = [1,2,3]. + _openVoting(0); + uint64[] memory rpIds = new uint64[](3); + rpIds[0] = 1; + rpIds[1] = 2; + rpIds[2] = 3; + uint64[] memory cs = new uint64[](3); + cs[0] = 10; + cs[1] = 10; + cs[2] = 10; + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](2); + votes[0] = _sign(pk1, 0, _counts(rpIds, cs)); + votes[1] = _sign(pk2, 0, _counts(rpIds, cs)); + billing.submitBillingVotes(0, votes); + _closeVoting(0); + + // maxSteps=2 finalizes rp1, rp2 only. + billing.finalizeEpochs(0, 2); + assertEq(billing.outstandingDebt(1), 100); + assertEq(billing.outstandingDebt(2), 100); + assertEq(billing.outstandingDebt(3), 0, "rp3 not yet finalized"); + + // Resume: finishes rp3 and closes the epoch. + billing.finalizeEpochs(0, 100); + assertEq(billing.outstandingDebt(3), 100); + } + + function test_Finalize_votelessEpochSkipIsBounded() public { + // Vote in epoch 0 and epoch 2; epoch 1 is voteless. + _voteSingle(0, 1, 10, _pks2()); + _voteSingle(2, 1, 10, _pks2()); + _closeVoting(2); + + // maxSteps=1: finalizes epoch 0's rp1. + billing.finalizeEpochs(2, 1); + assertEq(billing.outstandingDebt(1), 100); + + // maxSteps=1: closes epoch 0, no new debt. + billing.finalizeEpochs(2, 1); + assertEq(billing.outstandingDebt(1), 100, "epoch 0 close should not accrue"); + + // maxSteps=1: skips voteless epoch 1 (one close step), no new debt. + billing.finalizeEpochs(2, 1); + assertEq(billing.outstandingDebt(1), 100, "epoch 1 voteless skip should not accrue"); + + // maxSteps=1: finalizes epoch 2's rp1. + billing.finalizeEpochs(2, 1); + assertEq(billing.outstandingDebt(1), 200); + } + + function test_Finalize_revertsLatestClosedEpochTooLarge() public { + billing = _deploy(0, 1, 1, PAYMENT, _tiers(100, 10, 5), REBATE); + vm.warp(uint256(type(uint32).max) + 3); + + vm.expectRevert(IBillingContract.EpochTooLarge.selector); + billing.finalizeEpochs(type(uint32).max, 100); + } + + function test_LatestClosedEpoch_progression() public { + // Before epoch 0's voting window closes, no epoch is closed. + (,, bool exists, uint32 epoch) = billing.epochWatermarks(); + assertFalse(exists); + + // One second before epoch 0's voting window closes: still open. + vm.warp(GENESIS + EPOCH_LEN + VOTING - 1); + (,, exists,) = billing.epochWatermarks(); + assertFalse(exists); + + // Exactly at close: epoch 0 is the latest closed epoch. + vm.warp(GENESIS + EPOCH_LEN + VOTING); + (,, exists, epoch) = billing.epochWatermarks(); + assertTrue(exists); + assertEq(epoch, 0); + + // Advances with wall-clock time. + vm.warp(GENESIS + 5 * EPOCH_LEN + VOTING); + (,, exists, epoch) = billing.epochWatermarks(); + assertTrue(exists); + assertEq(epoch, 4); + } + + function test_LatestFinalizedEpoch_tracksCursor() public { + // Nothing finalized yet. + (bool exists, uint32 epoch,,) = billing.epochWatermarks(); + assertFalse(exists); + + _voteSingle(0, 1, 10, _pks2()); + _closeVoting(0); + (exists,,,) = billing.epochWatermarks(); + assertFalse(exists, "watermark only moves via finalizeEpochs"); + + billing.finalizeEpochs(0, 100); + (exists, epoch,,) = billing.epochWatermarks(); + assertTrue(exists); + assertEq(epoch, 0); + } + + function test_WindowEnds_deriveFromEpochBoundaries() public view { + // Voting opens at epochEnd and closes `votingWindow` later; payment closes `paymentWindow` after that. + assertEq(billing.votingWindowEnd(0), billing.epochEnd(0) + VOTING); + assertEq(billing.votingWindowEnd(0), GENESIS + EPOCH_LEN + VOTING); + assertEq(billing.paymentWindowEnd(0), billing.epochEnd(0) + VOTING + PAYMENT); + assertEq(billing.paymentWindowEnd(0), GENESIS + EPOCH_LEN + VOTING + PAYMENT); + + // Holds for later epochs too. + assertEq(billing.votingWindowEnd(3), billing.epochEnd(3) + VOTING); + assertEq(billing.paymentWindowEnd(3), billing.epochEnd(3) + VOTING + PAYMENT); + } + + function test_WindowEnds_governedByNextEraAtEraBoundary() public { + // A new era from epoch 1 with different window sizes. Epoch 0 is the last epoch of era 0, but + // its windows are governed by era 1 (_votingWindowEraOf(0) == _eraOf(1)), so the ends must use + // era 1's sizes even though epoch 0's boundary itself stays in era 0. + uint64 newVoting = VOTING - 50; + uint64 newPayment = PAYMENT + 50; + billing.setTiming(EPOCH_LEN, newVoting, newPayment); // era 1 starts at epoch 1 + + assertEq(billing.epochEnd(0), GENESIS + EPOCH_LEN, "epoch 0 keeps its historic boundary"); + assertEq(billing.votingWindowEnd(0), GENESIS + EPOCH_LEN + newVoting, "voting window uses next era"); + assertEq( + billing.paymentWindowEnd(0), GENESIS + EPOCH_LEN + newVoting + newPayment, "payment window uses next era" + ); + + // Fully inside era 1, the ends derive off era-1 boundaries with era-1 sizes. + assertEq(billing.votingWindowEnd(1), billing.epochEnd(1) + newVoting); + assertEq(billing.paymentWindowEnd(1), billing.epochEnd(1) + newVoting + newPayment); + } + + function test_Finalize_tierAcrossRebateBoundaryAndReset() public { + // Tiers: first 100 @10, rest @5. Rebate period = 10 epochs. + // epoch 0 (period 0): count 60 → 60*10 = 600. periodCount=60. + _voteSingle(0, 1, 60, _pks2()); + // epoch 1 (period 0): count 60 → base 60→120: 40@10 + 20@5 = 400+100 = 500. periodCount=120. + _voteSingle(1, 1, 60, _pks2()); + // epoch 10 (period 1): count 60 → period RESET, base 0→60: 60*10 = 600. + _voteSingle(10, 1, 60, _pks2()); + _closeVoting(10); + + billing.finalizeEpochs(0, 100); + assertEq(billing.outstandingDebt(1), 600, "epoch 0"); + + billing.finalizeEpochs(1, 100); + assertEq(billing.outstandingDebt(1), 1100, "epoch 1 crosses tier boundary"); + + billing.finalizeEpochs(10, 100); + // +600 (not +300) proves the rebate period reset at epoch 10. + assertEq(billing.outstandingDebt(1), 1700, "epoch 10 resets rebate period"); + } + + function test_Finalize_scheduleChangeAppliesToUnfinalized() public { + // No versioning: the tier schedule current at finalization prices every unfinalized epoch. + // epoch 0 votes while the rate is 10. + _voteSingle(0, 1, 10, _pks2()); + // Owner replaces the schedule (100@20, rest@10) before either epoch finalizes. + billing.setTierSchedule(_tiers(100, 20, 10)); + // epoch 1 votes. + _voteSingle(1, 2, 10, _pks2()); + _closeVoting(1); + + billing.finalizeEpochs(1, 100); + // Both epochs are priced at the current rate 20 — the old rate is not pinned. + assertEq(billing.outstandingDebt(1), 200, "epoch 0 re-priced at current rate 20"); + assertEq(billing.outstandingDebt(2), 200, "epoch 1 at current rate 20"); + } + + function test_PeerRotation_liveQuorumTracksNodeSet() public { + // No snapshot: quorum is read live at finalization from the current node set. + // First vote from node1 while n=3 (quorum 2). + _openVoting(0); + IBillingContract.SignedVoteChunk[] memory v1 = new IBillingContract.SignedVoteChunk[](1); + v1[0] = _sign(pk1, 0, _one(1, 50)); + billing.submitBillingVotes(0, v1); + + // Grow the live set to 5 mid-window; quorum is now 3, computed live. + address[] memory p = new address[](5); + p[0] = node1; + p[1] = node2; + p[2] = node3; + p[3] = node4; + p[4] = node5; + oprf.setPeers(p); + + // Second vote brings the count to 2, which misses the live quorum of 3. + IBillingContract.SignedVoteChunk[] memory v2 = new IBillingContract.SignedVoteChunk[](1); + v2[0] = _sign(pk2, 0, _one(1, 50)); + billing.submitBillingVotes(0, v2); + + _closeVoting(0); + billing.finalizeEpochs(0, 100); + assertEq(billing.outstandingDebt(1), 0, "2 votes miss the live quorum of 3"); + } + + //////////////////////////////////////////////////////////// + // Payment + blocking // + //////////////////////////////////////////////////////////// + + function _finalizeEpoch0Debt() internal returns (uint256 debt) { + _voteSingle(0, 1, 50, _pks2()); + _closeVoting(0); + billing.finalizeEpochs(0, 100); + debt = billing.outstandingDebt(1); + } + + function _fundPayer(uint256 amount) internal { + feeToken.mint(payer, amount); + vm.prank(payer); + feeToken.approve(address(billing), amount); + } + + function test_Pay_happy() public { + uint256 debt = _finalizeEpoch0Debt(); + _fundPayer(debt); + + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: type(uint256).max}); + + vm.expectEmit(true, true, false, true, address(billing)); + emit IBillingContract.DebtPaid(1, payer, 0, debt); + vm.prank(payer); + billing.pay(ps); + + assertEq(billing.outstandingDebt(1), 0); + assertEq(feeToken.balanceOf(feeRecipient), debt); + } + + function test_Pay_slippageRevert() public { + uint256 debt = _finalizeEpoch0Debt(); + _fundPayer(debt); + + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: debt - 1}); + vm.prank(payer); + vm.expectRevert(IBillingContract.DebtExceedsMax.selector); + billing.pay(ps); + } + + function test_Pay_skipsZeroDebt() public { + // No finalized debt for rp 1; pay must be a no-op (idempotent, no revert). + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: type(uint32).max, maxAmount: type(uint256).max}); + vm.prank(payer); + billing.pay(ps); + assertEq(feeToken.balanceOf(feeRecipient), 0); + } + + function test_Pay_batchedMultiRp() public { + // Finalize debt for rp 1 and rp 2 in epoch 0. + _openVoting(0); + uint64[] memory rpIds = new uint64[](2); + rpIds[0] = 1; + rpIds[1] = 2; + uint64[] memory cs = new uint64[](2); + cs[0] = 10; + cs[1] = 20; + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](2); + votes[0] = _sign(pk1, 0, _counts(rpIds, cs)); + votes[1] = _sign(pk2, 0, _counts(rpIds, cs)); + billing.submitBillingVotes(0, votes); + _closeVoting(0); + billing.finalizeEpochs(0, 100); + + uint256 debt1 = billing.outstandingDebt(1); // 10*10 + uint256 debt2 = billing.outstandingDebt(2); // 20*10 + assertEq(debt1, 100); + assertEq(debt2, 200); + _fundPayer(debt1 + debt2); + + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](2); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: type(uint256).max}); + ps[1] = IBillingContract.RpPayment({rpId: 2, uptoEpoch: 0, maxAmount: type(uint256).max}); + vm.prank(payer); + billing.pay(ps); + + assertEq(billing.outstandingDebt(1), 0); + assertEq(billing.outstandingDebt(2), 0); + assertEq(feeToken.balanceOf(feeRecipient), debt1 + debt2); + } + + /// @dev pay must NOT finalize; a closed-but-unfinalized epoch yields no payable debt. + function test_Pay_doesNotFinalize() public { + _voteSingle(0, 1, 50, _pks2()); + _closeVoting(0); + // Deliberately do not finalize. + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: type(uint256).max}); + vm.prank(payer); + billing.pay(ps); + assertEq(feeToken.balanceOf(feeRecipient), 0, "pay should not have finalized/charged"); + } + + function test_IsBlocked_lifecycle() public { + uint256 debt = _finalizeEpoch0Debt(); + uint64 due = billing.epochEnd(0) + VOTING + PAYMENT; + + // Within the payment window: not blocked. + vm.warp(due); + assertFalse(billing.isBlocked(1)); + + // Past the payment window: blocked. + vm.warp(due + 1); + assertTrue(billing.isBlocked(1)); + + // Pay clears the block. + _fundPayer(debt); + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: type(uint256).max}); + vm.prank(payer); + billing.pay(ps); + assertFalse(billing.isBlocked(1)); + } + + function test_IsBlocked_advancesToNextUnpaidEpochAfterPartialPayment() public { + _voteSingle(0, 1, 50, _pks2()); + _voteSingle(1, 1, 50, _pks2()); + _closeVoting(1); + billing.finalizeEpochs(1, 100); + + uint256 epoch0Debt = 500; + uint256 epoch1Debt = 500; + assertEq(billing.outstandingDebt(1), epoch0Debt + epoch1Debt); + + uint64 epoch0Due = billing.epochEnd(0) + VOTING + PAYMENT; + uint64 epoch1Due = billing.epochEnd(1) + VOTING + PAYMENT; + vm.warp(epoch0Due + 1); + assertTrue(billing.isBlocked(1), "epoch 0 is overdue"); + + _fundPayer(epoch0Debt); + IBillingContract.RpPayment[] memory ps = new IBillingContract.RpPayment[](1); + ps[0] = IBillingContract.RpPayment({rpId: 1, uptoEpoch: 0, maxAmount: epoch0Debt}); + vm.prank(payer); + billing.pay(ps); + + assertEq(billing.outstandingDebt(1), epoch1Debt); + assertFalse(billing.isBlocked(1), "epoch 1 is unpaid but not due yet"); + + vm.warp(epoch1Due + 1); + assertTrue(billing.isBlocked(1), "epoch 1 becomes the blocking epoch"); + } + + function test_IsBlocked_falseWithoutDebt() public view { + assertFalse(billing.isBlocked(999)); + } + + //////////////////////////////////////////////////////////// + // Owner functions // + //////////////////////////////////////////////////////////// + + function test_SetTierSchedule_ownerOnly() public { + vm.prank(payer); + vm.expectRevert(); + billing.setTierSchedule(_tiers(100, 20, 10)); + } + + function test_SetTierSchedule_revertsInvalidRatesNotDecreasing() public { + IBillingContract.Tier[] memory t = new IBillingContract.Tier[](2); + t[0] = IBillingContract.Tier({upTo: 100, rate: 5}); + t[1] = IBillingContract.Tier({upTo: type(uint256).max, rate: 10}); // not decreasing + vm.expectRevert(IBillingContract.InvalidTierSchedule.selector); + billing.setTierSchedule(t); + } + + function test_SetTierSchedule_revertsLastNotMax() public { + IBillingContract.Tier[] memory t = new IBillingContract.Tier[](1); + t[0] = IBillingContract.Tier({upTo: 100, rate: 10}); // last upTo != max + vm.expectRevert(IBillingContract.InvalidTierSchedule.selector); + billing.setTierSchedule(t); + } + + function test_SetTiming_revertsInvariant() public { + vm.expectRevert(IBillingContract.InvalidTiming.selector); + billing.setTiming(EPOCH_LEN, EPOCH_LEN + 1, PAYMENT); + } + + function test_SetTiming_ownerOnly() public { + vm.prank(payer); + vm.expectRevert(); + billing.setTiming(EPOCH_LEN, VOTING, PAYMENT); + } + + //////////////////////////////////////////////////////////// + // Timing changes (era history) // + //////////////////////////////////////////////////////////// + + function test_GetTiming_initialEra() public view { + (uint64 len, uint64 vw, uint64 pw, uint32 eraStartEpoch, uint64 eraStartTime) = billing.getTiming(); + assertEq(len, EPOCH_LEN); + assertEq(vw, VOTING); + assertEq(pw, PAYMENT); + assertEq(eraStartEpoch, 0); + assertEq(eraStartTime, GENESIS); + assertEq(billing.getEras().length, 1); + } + + function test_SetTiming_backlogFinalizesAfterChange() public { + // Epoch 0 is closed but not finalized; epoch 1's window is open with votes in flight. + _voteSingle(0, 1, 50, _pks2()); + _voteSingle(1, 1, 50, _pks2()); // warps to epochEnd(1): epoch 0 is now closed + + // The change succeeds despite the backlog; the new era starts at epoch 3. + billing.setTiming(EPOCH_LEN, VOTING, PAYMENT); + (,,, uint32 eraStartEpoch, uint64 eraStartTime) = billing.getTiming(); + assertEq(eraStartEpoch, 3); + assertEq(eraStartTime, GENESIS + 300); + + // Epoch 0 was already closed at the change: it finalizes immediately, undelayed. + billing.finalizeEpochs(type(uint32).max, 100); + assertEq(billing.outstandingDebt(1), 500, "closed backlog finalizes right away"); + + // Epoch 1 finalizes at its exact historic close, before the first new-era window closes. + vm.warp(eraStartTime); + billing.finalizeEpochs(type(uint32).max, 100); + assertEq(billing.outstandingDebt(1), 1000, "epoch 0 (500) + epoch 1 (500)"); + + // Epoch 0's deadline is its historic one (GENESIS + 400), unmoved by the change. + vm.warp(GENESIS + 400); + assertFalse(billing.isBlocked(1)); // exactly at the deadline + vm.warp(GENESIS + 401); + assertTrue(billing.isBlocked(1), "historic deadline unchanged by the change"); + } + + function test_SetTiming_openWindowUnaffectedByChange() public { + // Epoch 0's window is open (continuous voting) with no votes in yet; the change needs no + // precondition and does not touch the window. + vm.warp(GENESIS + 150); + billing.setTiming(200, 150, 300); // new era from epoch 2 (GENESIS + 200) + + // Nodes still vote for epoch 0 as if nothing happened... + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](2); + votes[0] = _sign(pk1, 0, _one(1, 50)); + votes[1] = _sign(pk2, 0, _one(1, 50)); + billing.submitBillingVotes(0, votes); + + // ...and the window closes at its historic time (era-0 votingWindow), not the new one. + vm.warp(GENESIS + 200); + IBillingContract.SignedVoteChunk[] memory late = new IBillingContract.SignedVoteChunk[](1); + late[0] = _sign(pk3, 0, _one(1, 70)); + vm.expectRevert(IBillingContract.VotingWindowClosed.selector); + billing.submitBillingVotes(0, late); + } + + function test_SetTiming_atGenesisStartsEraAtEpochOne() public { + // At genesis, epoch 0 is in flight: the new era starts right after it. + billing.setTiming(200, 100, PAYMENT); + (,,, uint32 eraStartEpoch, uint64 eraStartTime) = billing.getTiming(); + assertEq(eraStartEpoch, 1); + assertEq(eraStartTime, GENESIS + EPOCH_LEN); // in-flight epoch 0 keeps its boundary + assertEq(billing.epochEnd(1), GENESIS + EPOCH_LEN + 200); + } + + function test_SetTiming_pendingEraIsUpdatedInPlace() public { + billing.setTiming(200, 100, PAYMENT); // era from epoch 1 (GENESIS + EPOCH_LEN) + // The pending era has not started: a second change updates it in place. + billing.setTiming(300, 200, PAYMENT); + (uint64 len, uint64 vw,, uint32 eraStartEpoch, uint64 eraStartTime) = billing.getTiming(); + assertEq(len, 300); + assertEq(vw, 200); + assertEq(eraStartEpoch, 1); + assertEq(eraStartTime, GENESIS + EPOCH_LEN); + assertEq(billing.epochEnd(1), GENESIS + EPOCH_LEN + 300); + assertEq(billing.getEras().length, 2, "no third era for an in-place update"); + } + + function test_SetTiming_lateVotesSurviveEraChange() public { + // Two of three nodes voted for epoch 0; laggard node3 has not. + _voteSingle(0, 1, 50, _pks2()); // now = GENESIS + 100 + + vm.expectEmit(false, false, false, true, address(billing)); + emit IBillingContract.TimingUpdated(200, 150, 300, 2, GENESIS + 200); + billing.setTiming(200, 150, 300); + + // Historic and new boundaries: epoch 0 and the in-flight epoch 1 keep theirs; epoch 2 on + // uses the new length. + assertEq(billing.epochEnd(0), GENESIS + 100); + assertEq(billing.epochEnd(1), GENESIS + 200); + assertEq(billing.epochEnd(2), GENESIS + 400); + + // The laggard's vote still lands: epoch 0's window is open until its historic close. + vm.warp(GENESIS + 199); + IBillingContract.SignedVoteChunk[] memory late = new IBillingContract.SignedVoteChunk[](1); + late[0] = _sign(pk3, 0, _one(1, 80)); + billing.submitBillingVotes(0, late); + + // Epoch 1's window opens at the preserved boundary, governed by the new votingWindow. + _voteSingle(1, 1, 50, _pks2()); // warps to GENESIS + 200 + vm.warp(GENESIS + 200 + 150); + + // Epoch 0 finalizes over all three votes [50, 50, 80] -> median 50; nothing was lost. + billing.finalizeEpochs(type(uint32).max, 100); + assertEq(billing.outstandingDebt(1), 1000, "epoch 0 (500) + epoch 1 (500)"); + } + + function test_SetTiming_preservesHistoricDeadlines() public { + _finalizeEpoch0Debt(); // due at epochEnd(0) + VOTING + PAYMENT = GENESIS + 400 + vm.warp(GENESIS + 401); + assertTrue(billing.isBlocked(1), "epoch 0 debt is overdue"); + + // The change starts a new era but moves no existing deadline: the RP stays blocked. + billing.setTiming(200, 150, 300); + assertTrue(billing.isBlocked(1), "historic deadline is not affected by the change"); + } + + function test_LatestClosed_capsAtRegimeBoundary() public { + // Fresh deployment with a short voting window (40 < epoch length 100), then a change to a + // wider window (90): between the old close cadence and the new regime's first close, the + // boundary epoch's window is still open and must not be reported closed. + billing = _deploy(GENESIS, 100, 40, PAYMENT, _tiers(100, 10, 5), REBATE); + vm.warp(GENESIS + 250); // inside epoch 2, no window open + billing.setTiming(100, 90, PAYMENT); // era 1 from epoch 3 (GENESIS + 300) + + // Epoch 2's window opens at GENESIS + 300 and closes at +390 (new-era votingWindow). + vm.warp(GENESIS + 340); + IBillingContract.SignedVoteChunk[] memory votes = new IBillingContract.SignedVoteChunk[](2); + votes[0] = _sign(pk1, 2, _one(1, 50)); + votes[1] = _sign(pk2, 2, _one(1, 50)); + billing.submitBillingVotes(2, votes); + + // The old-era close cadence alone would report epoch 2 closed at +340; the regime cap + // keeps it open, so finalization must not consume it mid-window. + billing.finalizeEpochs(type(uint32).max, 100); + assertEq(billing.outstandingDebt(1), 0, "epoch 2 must not finalize mid-window"); + + vm.warp(GENESIS + 390); + billing.finalizeEpochs(type(uint32).max, 100); + assertEq(billing.outstandingDebt(1), 500, "epoch 2 finalizes at its true close"); + } + + function test_EpochEnd_historicAcrossEras() public { + vm.warp(GENESIS + 150); + billing.setTiming(200, 150, 300); // era 1 from epoch 2 (GENESIS + 200) + vm.warp(GENESIS + 450); // inside epoch 3 (era 1) + billing.setTiming(400, 300, 500); // era 2 from epoch 4 (GENESIS + 600) + + // Every epoch keeps the boundary of the era it belongs to. + assertEq(billing.epochEnd(0), GENESIS + 100); // era 0 + assertEq(billing.epochEnd(1), GENESIS + 200); // era 0 (its last epoch) + assertEq(billing.epochEnd(2), GENESIS + 400); // era 1 + assertEq(billing.epochEnd(3), GENESIS + 600); // era 1 (its last epoch) + assertEq(billing.epochEnd(4), GENESIS + 1000); // era 2 + + IBillingContract.TimingEra[] memory eras = billing.getEras(); + assertEq(eras.length, 3); + assertEq(eras[1].startEpoch, 2); + assertEq(eras[2].startTime, GENESIS + 600); + } + + function test_SetRebatePeriodEpochs() public { + billing.setRebatePeriodEpochs(20); + assertEq(billing.getRebatePeriodEpochs(), 20); + } + + function test_SetRebatePeriodEpochs_revertsZero() public { + vm.expectRevert(IBillingContract.InvalidTiming.selector); + billing.setRebatePeriodEpochs(0); + } + + function test_UpdateOprfKeyRegistry_ownerOnly() public { + vm.prank(payer); + vm.expectRevert(); + billing.updateOprfKeyRegistry(address(0xdead)); + } + + function test_UpdateOprfKeyRegistry_revertsZeroAddress() public { + vm.expectRevert(WorldIDBase.ZeroAddress.selector); + billing.updateOprfKeyRegistry(address(0)); + } +}