// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; interface IERC20BalanceTransfer { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } interface IPonsV2FeeEscrowClaim { function balanceOfToken(address recipient, address token) external view returns (uint256); function claimToken(address token) external returns (uint256 amount); } /** Immutable boundary that validates a drand signature and returns its canonical randomness. */ interface IGMDrawVerifier { function currentRound() external view returns (uint64); function verifyBeacon(uint64 futureRandomnessRound, bytes calldata signature) external view returns (bytes32 randomness); } /** * Minimal GM fee custody checkpoint. Pons pays this contract directly, so no * fee-recipient key or intermediate payout wallet exists. Claim and settlement * are permissionless, but only the immutable Pi executor may commit a draw. * No caller can choose a payee: the winner is derived from verifier-returned * drand randomness and a committed holder range tree. No caller can change a * destination, move an arbitrary amount, or invoke an arbitrary target. * * The committer can bias snapshot contents, although it commits before the * future drand result is knowable. Production therefore requires independently * authenticated snapshot provenance, an attestation, or a zero-knowledge proof; * snapshot holdings are not trustless merely because their hash and root are * committed onchain. * * A committed draw can never be recovered into another draw. If settlement is * blocked operationally, the recovery Safe pauses, repairs the verifier or * surrounding operation, unpauses, and retries the same immutable draw. * * This source is not deployed and has not been audited. Constructor bindings, * the range-tree builder, snapshot provenance, and the verifier remain * deployment review gates. */ contract GMContractCustody { /** * Minimum escrow credit, in USDG base units (6 decimals), that * `claimIfThreshold()` requires. Set once at deployment and immutable * thereafter, so a given deployment's threshold can never be changed by * any caller, key, or upgrade. * * Production uses 7_500_000 (7.50 USDG). A controlled test deployment may * use a smaller floor so that a short draw interval can complete real * claim -> commit -> settle cycles without waiting for production-sized * fee accrual. The value is a deployment review gate: it appears in the * constructor arguments and is readable on-chain. * * Must be greater than zero. A zero floor would let `claimIfThreshold()` * be called against an empty escrow, which then reverts `NoClaimReceived` * after paying gas. */ uint256 public immutable MINIMUM_CLAIMABLE_USDG; IPonsV2FeeEscrowClaim public immutable PONS_ESCROW; address public immutable GM_TOKEN; IERC20BalanceTransfer public immutable USDG; address public immutable RESERVE_SAFE; address public immutable RECOVERY_SAFE; address public immutable COMMITTER; IGMDrawVerifier public immutable DRAW_VERIFIER; enum DrawStatus { NONE, COMMITTED, SETTLED } struct Draw { bytes32 snapshotHash; bytes32 rangeRoot; uint256 totalTickets; uint64 commitmentRound; uint64 futureRandomnessRound; uint256 amount; DrawStatus status; } struct RangeProof { address holder; uint256 startInclusive; uint256 endExclusive; uint256 leafIndex; bytes32[] siblings; } mapping(bytes32 drawId => Draw draw) public draws; uint256 public availableBalance; /** * Sum of every COMMITTED, unsettled draw's locked amount. Tracked so that * `sweepDonations()` can distinguish USDG that belongs to a live draw from * USDG that arrived by direct transfer. Increased in `commitDraw`, reduced * in `settleDraw` by the same amount that is paid out. */ uint256 public committedBalance; bool public paused; uint256 private _entered; error ZeroAddress(); error ZeroThreshold(); error Paused(); error NotRecoverySafe(); error NotCommitter(); error ReentrantCall(); error BelowClaimThreshold(uint256 claimable); error NoClaimReceived(); error DuplicateDraw(); error InvalidCommitment(); error NoAvailableBalance(); error NothingToSweep(); error RandomnessNotFuture(); error DrawNotCommitted(); error InvalidDrawProof(); error TransferFailed(); error InexactTransfer(); event FeesClaimed(address indexed executor, uint256 measuredAmount); event DonationsSwept(address indexed executor, uint256 sweptAmount); event DrawCommitted( bytes32 indexed drawId, bytes32 indexed snapshotHash, bytes32 indexed rangeRoot, uint256 totalTickets, uint64 commitmentRound, uint64 futureRandomnessRound, uint256 amount, uint256 commitmentBlock ); event DrawSettled( bytes32 indexed drawId, uint256 winningTicket, address indexed winner, uint256 winnerAmount, address indexed reserveSafe, uint256 reserveAmount ); event PauseChanged(bool paused); modifier nonReentrant() { if (_entered != 0) revert ReentrantCall(); _entered = 1; _; _entered = 0; } modifier whenRunning() { if (paused) revert Paused(); _; } modifier onlyRecoverySafe() { if (msg.sender != RECOVERY_SAFE) revert NotRecoverySafe(); _; } modifier onlyCommitter() { if (msg.sender != COMMITTER) revert NotCommitter(); _; } constructor( IPonsV2FeeEscrowClaim ponsEscrow, address gmToken, IERC20BalanceTransfer usdg, address reserveSafe, address recoverySafe, address committer, IGMDrawVerifier drawVerifier, uint256 minimumClaimableUsdg ) { if ( address(ponsEscrow) == address(0) || gmToken == address(0) || address(usdg) == address(0) || reserveSafe == address(0) || recoverySafe == address(0) || committer == address(0) || address(drawVerifier) == address(0) ) revert ZeroAddress(); if (minimumClaimableUsdg == 0) revert ZeroThreshold(); PONS_ESCROW = ponsEscrow; GM_TOKEN = gmToken; USDG = usdg; RESERVE_SAFE = reserveSafe; RECOVERY_SAFE = recoverySafe; COMMITTER = committer; DRAW_VERIFIER = drawVerifier; MINIMUM_CLAIMABLE_USDG = minimumClaimableUsdg; } /** Permissionless and inclusive at exactly MINIMUM_CLAIMABLE_USDG. */ function claimIfThreshold() external nonReentrant whenRunning returns (uint256 claimedAmount) { uint256 claimable = PONS_ESCROW.balanceOfToken(address(this), address(USDG)); if (claimable < MINIMUM_CLAIMABLE_USDG) revert BelowClaimThreshold(claimable); uint256 balanceBefore = USDG.balanceOf(address(this)); PONS_ESCROW.claimToken(address(USDG)); uint256 balanceAfter = USDG.balanceOf(address(this)); if (balanceAfter <= balanceBefore) revert NoClaimReceived(); claimedAmount = balanceAfter - balanceBefore; availableBalance += claimedAmount; emit FeesClaimed(msg.sender, claimedAmount); } /** * Rolls directly-transferred USDG into the next draw's pot. * * The contract cannot observe an incoming ERC-20 transfer, so USDG sent * straight to this address is invisible to `availableBalance` and would * otherwise be stranded permanently. This function measures the gap * between the real token balance and the two tracked buckets, and credits * the difference to `availableBalance`. * * Permissionless, like `claimIfThreshold`. Swept funds always land in the * NEXT draw, never a pending one, so every published receipt still matches * the amount fixed at commit time. * * SAFETY — the `nonReentrant` guard on this function and on `settleDraw` * is LOAD-BEARING. Do not remove it, and do not add an unguarded variant * of this function. * * Subtracting `committedBalance` is NOT by itself sufficient. Inside * `settleDraw` there is a transient window between the winner transfer and * the reserve transfer where `committedBalance` has already been reduced by * the full draw amount while the reserve's share is still held by this * contract. In that window `held - accounted == reserveAmount > 0`, so a * reentrant call here would credit `availableBalance` with tokens that are * about to leave. `availableBalance` would then permanently exceed the real * balance, a later `commitDraw` would lock more than exists, `settleDraw` * would revert on transfer, and with no owner key every remaining fund * would be stranded forever. * * That window is unreachable only because `nonReentrant` serialises these * functions and USDG performs no recipient callback. USDG is an upgradeable * proxy, so its no-callback behaviour is not guaranteed for all time; the * guard is the durable protection. */ function sweepDonations() external nonReentrant whenRunning returns (uint256 sweptAmount) { uint256 held = USDG.balanceOf(address(this)); uint256 accounted = availableBalance + committedBalance; if (held <= accounted) revert NothingToSweep(); sweptAmount = held - accounted; availableBalance += sweptAmount; emit DonationsSwept(msg.sender, sweptAmount); } /** Locks all currently available claimed USDG to one immutable draw. */ function commitDraw( bytes32 drawId, bytes32 snapshotHash, bytes32 rangeRoot, uint256 totalTickets, uint64 futureRandomnessRound ) external onlyCommitter nonReentrant whenRunning returns (uint256 amount) { if (drawId == bytes32(0) || snapshotHash == bytes32(0) || rangeRoot == bytes32(0) || totalTickets == 0) { revert InvalidCommitment(); } if (draws[drawId].status != DrawStatus.NONE) revert DuplicateDraw(); uint64 commitmentRound = DRAW_VERIFIER.currentRound(); if (futureRandomnessRound <= commitmentRound) revert RandomnessNotFuture(); amount = availableBalance; if (amount == 0) revert NoAvailableBalance(); availableBalance = 0; committedBalance += amount; draws[drawId] = Draw({ snapshotHash: snapshotHash, rangeRoot: rangeRoot, totalTickets: totalTickets, commitmentRound: commitmentRound, futureRandomnessRound: futureRandomnessRound, amount: amount, status: DrawStatus.COMMITTED }); emit DrawCommitted( drawId, snapshotHash, rangeRoot, totalTickets, commitmentRound, futureRandomnessRound, amount, block.number ); } /** * Verifies the committed beacon, derives the winning ticket onchain, and * derives its owner from a Merkle range proof. There is no randomness or * winner argument: canonical randomness comes only from DRAW_VERIFIER. */ function settleDraw( bytes32 drawId, bytes calldata drandSignature, RangeProof calldata rangeProof ) external nonReentrant whenRunning returns (uint256 winnerAmount, uint256 reserveAmount) { Draw storage draw = draws[drawId]; if (draw.status != DrawStatus.COMMITTED) revert DrawNotCommitted(); bytes32 randomness = DRAW_VERIFIER.verifyBeacon(draw.futureRandomnessRound, drandSignature); uint256 winningTicket = uint256(randomness) % draw.totalTickets; address winner = _verifyRangeProof(draw.rangeRoot, draw.totalTickets, winningTicket, rangeProof); uint256 amount = draw.amount; reserveAmount = (amount * 10) / 100; winnerAmount = amount - reserveAmount; draw.status = DrawStatus.SETTLED; // winnerAmount + reserveAmount == amount exactly (winnerAmount is derived // by subtraction, not a second division), so this releases the full // locked sum. Done before any transfer (checks-effects-interactions). // // NOTE: this decrement opens a transient window across the two transfers // below in which `availableBalance + committedBalance` under-counts the // tokens still held. `sweepDonations()` would misread that gap as a // donation. The `nonReentrant` guard on both functions is what makes the // window unreachable — see the SAFETY note on `sweepDonations`. committedBalance -= amount; _safeTransferExact(winner, winnerAmount); _safeTransferExact(RESERVE_SAFE, reserveAmount); emit DrawSettled(drawId, winningTicket, winner, winnerAmount, RESERVE_SAFE, reserveAmount); } function pause() external onlyRecoverySafe { paused = true; emit PauseChanged(true); } function unpause() external onlyRecoverySafe { paused = false; emit PauseChanged(false); } function _verifyRangeProof( bytes32 rangeRoot, uint256 totalTickets, uint256 winningTicket, RangeProof calldata proof ) private pure returns (address holder) { holder = proof.holder; if ( holder == address(0) || proof.startInclusive >= proof.endExclusive || proof.endExclusive > totalTickets || winningTicket < proof.startInclusive || winningTicket >= proof.endExclusive || proof.siblings.length > 64 ) revert InvalidDrawProof(); bytes32 node = keccak256(abi.encode( "GM_HOLDER_RANGE_V1", holder, proof.startInclusive, proof.endExclusive, totalTickets )); uint256 index = proof.leafIndex; for (uint256 offset = 0; offset < proof.siblings.length; ++offset) { bytes32 sibling = proof.siblings[offset]; node = (index & 1) == 0 ? keccak256(abi.encodePacked(node, sibling)) : keccak256(abi.encodePacked(sibling, node)); index >>= 1; } if (index != 0 || node != rangeRoot) revert InvalidDrawProof(); } function _safeTransferExact(address recipient, uint256 amount) private { uint256 senderBefore = USDG.balanceOf(address(this)); uint256 recipientBefore = USDG.balanceOf(recipient); bool ok = USDG.transfer(recipient, amount); if (!ok) revert TransferFailed(); uint256 senderAfter = USDG.balanceOf(address(this)); uint256 recipientAfter = USDG.balanceOf(recipient); if (senderBefore - senderAfter != amount || recipientAfter - recipientBefore != amount) revert InexactTransfer(); } }