// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IMining} from "./interfaces/IMining.sol"; import {IReceiptDistributor} from "./interfaces/IReceiptDistributor.sol"; import {PoolMath} from "./PoolMath.sol"; import {TapeoutEntitlementReceipt} from "./TapeoutEntitlementReceipt.sol"; /// @title TapeOut miner custody and BEM revenue distribution pool /// @notice Holds TapeOut miner NFTs and distributes the BEM it actually receives in the relative /// weights NAND + LATCH 100 : A 1.25 : B 1.25 : C 2.5, held exactly as 80 : 1 : 1 : 2 of 84 /// parts. The participants' part goes to the NAND and LATCH receipt collections: income the /// admitted miners earn in the pool is split by their aggregate NAND/LATCH burn, and income an /// incoming miner carries in (earned before its admission) by that miner's own burn, both as /// read from the Mining contract. Receipt holders claim per receipt ID and unclaimed income /// follows the ID. A and B are role recipients. C is the fixed sink address `SINK`: every /// release of A or B also transfers C's whole unpaid part there, as a plain BEM transfer (not a /// token burn). /// @dev Lifecycle: deploy (binds receipts, recipients and a fixed depositor whitelist) -> owner /// `bindDistributor` (once) -> owner `activate` (receipt supplies finalized and fixed as /// denominators, allocation finalized) -> every owner renounces (both receipts, the distributor, /// this pool) -> whitelisted admissions, harvests, claims and releases. The first admission /// requires that finality (`_openCustody`); no admission can renounce anything. There is no miner /// withdrawal, BEM withdrawal, pause, upgrade, or arbitrary call path. /// /// Admission (`depositMiners`, `registerMiners`) is an atomic accounting boundary: /// 1. validate the batch and read each incoming miner's burn from Mining; /// 2. recognize BEM already held, under the old composition; /// 3. call `Mining.claim` for every registered miner and recognize it under the old composition /// (a failed claim reverts the whole admission, unless Mining reports the miner REVOKED); /// 4. checkpoint the old period (category entitlements frozen) and take custody; /// 5. claim each incoming miner on its own and attribute exactly what that claim paid by the /// miner's own burn (carried income, never re-split by the pool's composition); /// 6. add the batch to the composition totals and recognize anything else held under them. contract TapeoutMinerPool is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; enum Category { NAND, LATCH } enum Recipient { A, B } struct Deployment { address owner; address bem; address mining; address minerCollection; address nandReceipt; address latchReceipt; address recipientA; address recipientB; address[] depositors; string poolName; } struct Miner { bytes32 key; uint32 nandBurn; uint32 latchBurn; uint64 admittedInPeriod; bool registered; /// @dev BEM its own Mining.claim paid during its admission (its carried income). uint120 carriedIncome; } /// @notice bemBalance + deficit == sum(accrued - paid) + residue + unrecognized. struct Reconciliation { uint256 bemBalance; uint256 unrecognized; uint256 deficit; uint256 totalAccounted; uint256 totalPaid; uint256 recipientAAccrued; uint256 recipientAPaid; uint256 recipientBAccrued; uint256 recipientBPaid; uint256 sinkAccrued; uint256 sinkPaid; uint256 nandIncome; uint256 nandReceiptsAccrued; uint256 nandReceiptsPaid; uint256 latchIncome; uint256 latchReceiptsAccrued; uint256 latchReceiptsPaid; uint256 residue; } string public constant VERSION = "TapeoutMinerPool/2.3.0"; uint256 public constant TOTAL_PARTS = PoolMath.TOTAL_PARTS; uint256 public constant PARTICIPANT_PARTS = PoolMath.PARTICIPANT_PARTS; uint256 public constant RECIPIENT_PARTS = PoolMath.RECIPIENT_PARTS; uint256 public constant SINK_PARTS = PoolMath.SINK_PARTS; /// @notice Category ledger units per raw BEM unit (`ledgerState`, `carriedNandUnits`, events). uint256 public constant LEDGER_UNIT = PoolMath.LEDGER_UNIT; /// @notice C: the fixed destination of the sink part. Payments are ordinary BEM transfers to this /// address (they need no signature from it); nobody can change or redirect it, and it is /// not an A/B role. Whether BEM's totalSupply changes on such a transfer is BEM's behaviour. address public constant SINK = 0x000000000000000000000000000000000000dEaD; uint8 public constant MINER_ACTIVE = 1; /// @dev Mining status 2. Permanent: a revoked circuit can never mine again, and Mining refuses /// every claim for it (docs/VERIFICATION_REPORT.md, Mining lifecycle). uint8 public constant MINER_REVOKED = 2; uint256 public constant MAX_DEPOSITORS = 16; uint256 public constant MAX_ADMISSION_BATCH = 50; uint256 public constant MAX_PAGE = 500; bytes32 internal constant NAND_CATEGORY = keccak256("TAPEOUT_NAND_ENTITLEMENT"); bytes32 internal constant LATCH_CATEGORY = keccak256("TAPEOUT_LATCH_ENTITLEMENT"); IERC20 public immutable bem; IMining public immutable mining; IERC721 public immutable minerCollection; TapeoutEntitlementReceipt public immutable nandReceipt; TapeoutEntitlementReceipt public immutable latchReceipt; string public poolName; address public recipientA; address public recipientB; address[] private _depositors; mapping(address account => bool allowed) public isDepositor; /// @notice The round's receipt distributor, bound once before activation; never replaceable. address public distributor; bool public active; uint64 public activatedAt; /// @notice Set by the first admission once every initialization role is finalized and renounced. bool public custodyOpen; uint256 public nandShares; uint256 public latchShares; uint256 public totalNandBurn; uint256 public totalLatchBurn; uint256 public compositionPeriod; /// @dev The last admission boundary (checkpoint or carried attribution), read through `ledgerState`: /// the recognized total there and the NAND and LATCH entitlements in ledger units (1 / /// LEDGER_UNIT raw BEM). Always nandBaseUnits + latchBaseUnits == accountedBase * 80 * 1e18. uint256 private accountedBase; uint256 private nandBaseUnits; uint256 private latchBaseUnits; /// @notice Cumulative carried income (raw BEM) and its NAND part in ledger units; its LATCH part is /// carriedIncome * 80 * 1e18 - carriedNandUnits. uint256 public carriedIncome; uint256 public carriedNandUnits; uint256 public totalAccounted; uint256 public totalPaid; uint256 public recipientAPaid; uint256 public recipientBPaid; uint256 public sinkPaid; uint256[2] private _receiptsPaid; mapping(Category => mapping(uint256 id => uint256 amount)) private _claimed; uint256[] private _minerIds; bytes32[] private _minerKeys; mapping(uint256 tokenId => Miner) private _miners; error AlreadyActive(); error NotActive(); error InvalidAddress(); error InvalidName(); error InvalidDepositors(); error IncompatibleReceipt(); error IssuanceNotFinalized(); error UnexpectedShareCounts(uint256 nandShares, uint256 latchShares); error NoShares(); error NotDepositor(address caller); error InvalidBatch(); error DuplicateMiner(uint256 tokenId); error MinerAlreadyRegistered(uint256 tokenId); error MinerNotHeld(uint256 tokenId, address holder); error MinerNotActive(uint256 tokenId); error CompositionMismatch(uint256 tokenId); error EmptyComposition(uint256 tokenId); error UnsupportedCategory(Category category); error SettlementFailed(uint256 tokenId, bytes reason); error UnknownMiner(uint256 tokenId); error ReceiptIdOutOfRange(Category category, uint256 id); error NotHolder(Category category, uint256 id); error InvalidPage(); error Unauthorized(); error BalanceDecreased(uint256 balance, uint256 expected); error UnexpectedTokenBehavior(); error DistributorAlreadyBound(); error InvalidDistributor(); error DistributorNotBound(); error AllocationNotFinalized(); error OwnershipNotRenounced(address target); event Deployed( address nandReceipt, address latchReceipt, address recipientA, address recipientB, address[] depositors ); event DistributorBound(address indexed distributor); event Activated(uint256 nandShares, uint256 latchShares, uint256 activatedAt); /// @notice The first admission found every initialization role finalized and renounced. event CustodyOpened(address indexed distributor, uint256 nandShares, uint256 latchShares); event IncomeRecognized(uint256 received, uint256 totalAccounted, uint256 compositionPeriod); /// @notice Category entitlements frozen when an admission closes the old period (ledger units). event CompositionCheckpoint( uint256 indexed period, uint256 totalAccounted, uint256 nandBaseUnits, uint256 latchBaseUnits ); /// @notice What an incoming miner's own claim paid during its admission, and the NAND and LATCH /// ledger units it added by that miner's burn. event CarriedIncomeAttributed( uint256 indexed tokenId, uint256 received, uint256 nandUnits, uint256 latchUnits ); event MinerRegistered( uint256 indexed tokenId, bytes32 indexed key, address indexed depositor, uint256 nandBurn, uint256 latchBurn ); event RevokedMinerSkipped(uint256 indexed tokenId); /// @param newPeriodIncome BEM held but not attributable to any miner (only possible at the first /// admission, when nothing could be recognized before), recognized under the new composition. event MinersAdmitted( address indexed depositor, uint256 indexed period, uint256 count, uint256 settledOldIncome, uint256 carriedIncome, uint256 newPeriodIncome, uint256 totalNandBurn, uint256 totalLatchBurn ); event MinerHarvested(uint256 indexed tokenId, uint256 received); event MinerHarvestFailed(uint256 indexed tokenId, bytes reason); event Harvested(address indexed caller, uint256 attempted, uint256 failed, uint256 received); event ReceiptIncomeClaimed( Category indexed category, uint256 indexed id, address indexed holder, uint256 amount ); event RecipientPaid(Recipient indexed role, address indexed recipient, uint256 amount); /// @notice C's part transferred to the sink by a release of `releasedRole` (A or B). event SinkPaid(Recipient indexed releasedRole, address indexed sink, uint256 amount); event RecipientChanged(Recipient indexed role, address indexed previous, address indexed current); constructor(Deployment memory d) Ownable(d.owner) { if (d.bem.code.length == 0 || d.mining.code.length == 0 || d.minerCollection.code.length == 0) { revert InvalidAddress(); } if (bytes(d.poolName).length == 0 || bytes(d.poolName).length > 64) revert InvalidName(); // Bind only to this exact receipt implementation (unique IDs, fixed supply after finalization). bytes32 receiptCode = keccak256(type(TapeoutEntitlementReceipt).runtimeCode); if ( d.nandReceipt == d.latchReceipt || d.nandReceipt.codehash != receiptCode || d.latchReceipt.codehash != receiptCode || TapeoutEntitlementReceipt(d.nandReceipt).category() != NAND_CATEGORY || TapeoutEntitlementReceipt(d.latchReceipt).category() != LATCH_CATEGORY ) revert IncompatibleReceipt(); if ( d.recipientA == address(0) || d.recipientB == address(0) || d.recipientA == address(this) || d.recipientB == address(this) || d.recipientA == SINK || d.recipientB == SINK ) revert InvalidAddress(); uint256 count = d.depositors.length; if (count == 0 || count > MAX_DEPOSITORS) revert InvalidDepositors(); for (uint256 i; i < count; ++i) { address a = d.depositors[i]; if ( a == address(0) || a == address(this) || isDepositor[a] || a == d.bem || a == d.mining || a == d.minerCollection || a == d.nandReceipt || a == d.latchReceipt ) revert InvalidDepositors(); isDepositor[a] = true; _depositors.push(a); } bem = IERC20(d.bem); mining = IMining(d.mining); minerCollection = IERC721(d.minerCollection); nandReceipt = TapeoutEntitlementReceipt(d.nandReceipt); latchReceipt = TapeoutEntitlementReceipt(d.latchReceipt); recipientA = d.recipientA; recipientB = d.recipientB; poolName = d.poolName; emit Deployed(d.nandReceipt, d.latchReceipt, d.recipientA, d.recipientB, d.depositors); } // --------------------------------------------------------------------------------------------- // Initialization (owner): bind the distributor once, then activate once // --------------------------------------------------------------------------------------------- /// @notice Bind the round's receipt distributor, once, before activation. It must count this pool /// among its two fixed pools (a processor's Optimal and Regular Pool share one distributor and /// one receipt pair, and each binds it) and report these receipts. Nothing can replace it /// afterwards. The pool cannot check its code (its immutables differ per deployment); the /// operator's deployed-code identity check does. function bindDistributor(address distributor_) external onlyOwner { // Activation requires a bound distributor, so a pool that is active is bound as well. if (distributor != address(0)) revert DistributorAlreadyBound(); IReceiptDistributor d = IReceiptDistributor(distributor_); if ( distributor_.code.length == 0 || !d.isPool(address(this)) || d.nandReceipt() != address(nandReceipt) || d.latchReceipt() != address(latchReceipt) ) revert InvalidDistributor(); distributor = distributor_; emit DistributorBound(distributor_); } /// @notice Fix both category denominators. Both receipt collections must have finalized issuance, /// the supplies must equal the values the operator reviewed, and the bound distributor's /// allocation must be finalized. function activate(uint256 expectedNandShares, uint256 expectedLatchShares) external onlyOwner { if (active) revert AlreadyActive(); if (!nandReceipt.issuanceFinalized() || !latchReceipt.issuanceFinalized()) { revert IssuanceNotFinalized(); } uint256 sN = nandReceipt.totalSupply(); uint256 sL = latchReceipt.totalSupply(); if (sN != expectedNandShares || sL != expectedLatchShares) revert UnexpectedShareCounts(sN, sL); if (sN + sL == 0) revert NoShares(); if (distributor == address(0)) revert DistributorNotBound(); if (!IReceiptDistributor(distributor).allocationFinalized()) revert AllocationNotFinalized(); nandShares = sN; latchShares = sL; active = true; activatedAt = uint64(block.timestamp); emit Activated(sN, sL, block.timestamp); } // --------------------------------------------------------------------------------------------- // Admission (whitelisted depositors, ACTIVE, every initialization role finalized and renounced) // --------------------------------------------------------------------------------------------- /// @notice Admit miners owned by the caller (requires ERC-721 approval to this pool). The supported /// admission path: validation, custody transfer, registration and accounting are atomic. /// @return settledOldIncome BEM collected from the registered miners under the old composition. /// @return carried BEM the incoming miners' own claims paid, attributed by each miner's burn. function depositMiners(uint256[] calldata tokenIds) external nonReentrant returns (uint256 settledOldIncome, uint256 carried) { return _admit(tokenIds, true); } /// @notice Admit miners that already reached the pool by a plain transfer. Same whitelist and /// the same settlement boundary as `depositMiners`; a plain transfer alone admits nothing. /// Not the supported production path: income claimed for such a miner before this call is /// booked as the pool's income at that moment, and a miner revoked in between can never be /// admitted (docs/OPERATIONS.md section 9). function registerMiners(uint256[] calldata tokenIds) external nonReentrant returns (uint256 settledOldIncome, uint256 carried) { return _admit(tokenIds, false); } // --------------------------------------------------------------------------------------------- // Income collection (anyone, ACTIVE) // --------------------------------------------------------------------------------------------- /// @notice Recognize BEM that reached the pool by any route as income of the current period. function sync() external nonReentrant returns (uint256 received) { _requireActive(); received = _sync(); } /// @notice Ordinary harvest: claim the listed registered miners under the current composition. /// Failures are isolated per miner and reported; only actual balance increases count. function harvest(uint256[] calldata tokenIds) external nonReentrant returns (uint256 received, uint256 failed) { _requireActive(); if (tokenIds.length == 0) revert InvalidBatch(); _sync(); uint256 balance = bem.balanceOf(address(this)); for (uint256 i; i < tokenIds.length; ++i) { Miner storage m = _miners[tokenIds[i]]; if (!m.registered) revert UnknownMiner(tokenIds[i]); bool ok; (ok, balance) = _harvestOne(tokenIds[i], m.key, balance); if (!ok) ++failed; } received = _sync(); emit Harvested(msg.sender, tokenIds.length, failed, received); } /// @notice Harvest registry entries [start, start+count) (clamped to the registry length). function harvestRange(uint256 start, uint256 count) external nonReentrant returns (uint256 received, uint256 failed) { _requireActive(); uint256 length = _minerIds.length; if (count == 0 || start >= length) revert InvalidBatch(); uint256 end = count > length - start ? length : start + count; _sync(); uint256 balance = bem.balanceOf(address(this)); for (uint256 i = start; i < end; ++i) { bool ok; (ok, balance) = _harvestOne(_minerIds[i], _minerKeys[i], balance); if (!ok) ++failed; } received = _sync(); emit Harvested(msg.sender, end - start, failed, received); } // --------------------------------------------------------------------------------------------- // Claims and recipient payments (ACTIVE) // --------------------------------------------------------------------------------------------- /// @notice Claim the income attached to one receipt ID held by the caller. function claim(Category category, uint256 id) external nonReentrant returns (uint256 amount) { _requireActive(); _sync(); PoolMath.Accrual memory a = _accrual(totalAccounted); uint256 perShare = category == Category.NAND ? a.perNandShare : a.perLatchShare; amount = _claimOne(category, id, perShare, shareCount(category)); _receiptsPaid[uint256(category)] += amount; _pay(msg.sender, amount); } /// @notice Claim the income attached to any number of NAND and LATCH IDs held by the caller. function claimBatch(uint256[] calldata nandIds, uint256[] calldata latchIds) external nonReentrant returns (uint256 amount) { _requireActive(); if (nandIds.length == 0 && latchIds.length == 0) revert InvalidBatch(); _sync(); PoolMath.Accrual memory a = _accrual(totalAccounted); amount = _claimMany(Category.NAND, nandIds, a.perNandShare) + _claimMany(Category.LATCH, latchIds, a.perLatchShare); _pay(msg.sender, amount); } /// @notice Pay recipient A its unpaid 1/84 and, in the same transaction, C its whole unpaid 2/84 /// to `SINK`, both at one income snapshot. Callable by anyone; B is not affected. /// @return amount Paid to A (C's payment is reported by `SinkPaid`). function releaseA() external nonReentrant returns (uint256 amount) { return _release(Recipient.A); } /// @notice Pay recipient B its unpaid 1/84 and, in the same transaction, C its whole unpaid 2/84 /// to `SINK`, both at one income snapshot. Callable by anyone; A is not affected. /// @return amount Paid to B (C's payment is reported by `SinkPaid`). function releaseB() external nonReentrant returns (uint256 amount) { return _release(Recipient.B); } /// @notice A recipient may move its own role (e.g. to a new multisig). Unpaid income stays with /// the role. Nobody else, including the owner, can change a recipient. C is not a role and /// cannot be moved, and no role may be moved to C's sink address. function setRecipient(Recipient role, address newRecipient) external { address current = role == Recipient.A ? recipientA : recipientB; if (msg.sender != current) revert Unauthorized(); if (newRecipient == address(0) || newRecipient == address(this) || newRecipient == SINK) { revert InvalidAddress(); } if (role == Recipient.A) recipientA = newRecipient; else recipientB = newRecipient; emit RecipientChanged(role, current, newRecipient); } // --------------------------------------------------------------------------------------------- // Views // --------------------------------------------------------------------------------------------- function depositors() external view returns (address[] memory) { return _depositors; } function receiptOf(Category category) public view returns (TapeoutEntitlementReceipt) { return category == Category.NAND ? nandReceipt : latchReceipt; } function shareCount(Category category) public view returns (uint256) { return category == Category.NAND ? nandShares : latchShares; } /// @notice The accounting state the allocation depends on (2.2.0 layout; renamed from 2.1.0's /// `state()`, whose tuple had other fields, so that an old decoder fails instead of misreading). function ledgerState() public view returns (PoolMath.State memory) { return PoolMath.State({ accountedBase: accountedBase, nandBase: nandBaseUnits, latchBase: latchBaseUnits, totalNandBurn: totalNandBurn, totalLatchBurn: totalLatchBurn, nandShares: nandShares, latchShares: latchShares }); } /// @notice Allocation of the income already recognized (totalAccounted). function accrual() external view returns (PoolMath.Accrual memory) { return _accrual(totalAccounted); } /// @notice Allocation after the next sync (adds BEM held but not yet recognized). Income still /// inside Mining is not included anywhere: it becomes income only when it is received. function previewAccrual() public view returns (PoolMath.Accrual memory) { return _accrual(totalAccounted + unrecognizedBalance()); } /// @notice BEM held beyond the ledger. Stays unrecognized while no miner has been admitted. function unrecognizedBalance() public view returns (uint256) { uint256 balance = bem.balanceOf(address(this)); uint256 expected = totalAccounted - totalPaid; if (balance < expected) revert BalanceDecreased(balance, expected); if (totalNandBurn + totalLatchBurn == 0) return 0; return balance - expected; } function claimed(Category category, uint256 id) external view returns (uint256) { return _claimed[category][id]; } function receiptsPaid(Category category) external view returns (uint256) { return _receiptsPaid[uint256(category)]; } /// @notice Income the current holder of `id` can claim now (including unrecognized balance). function claimable(Category category, uint256 id) public view returns (uint256) { if (id == 0 || id > shareCount(category)) revert ReceiptIdOutOfRange(category, id); PoolMath.Accrual memory a = previewAccrual(); uint256 perShare = category == Category.NAND ? a.perNandShare : a.perLatchShare; return perShare - _claimed[category][id]; } function claimableBatch(Category category, uint256[] calldata ids) external view returns (uint256[] memory amounts, uint256 total) { amounts = new uint256[](ids.length); if (ids.length == 0) return (amounts, 0); uint256 limit = shareCount(category); PoolMath.Accrual memory a = previewAccrual(); uint256 perShare = category == Category.NAND ? a.perNandShare : a.perLatchShare; mapping(uint256 => uint256) storage history = _claimed[category]; for (uint256 i; i < ids.length; ++i) { if (ids[i] == 0 || ids[i] > limit) revert ReceiptIdOutOfRange(category, ids[i]); amounts[i] = perShare - history[ids[i]]; total += amounts[i]; } } function recipientClaimable(Recipient role) external view returns (uint256) { uint256 accrued = previewAccrual().recipientA; // A and B accrue identically return accrued - (role == Recipient.A ? recipientAPaid : recipientBPaid); } /// @notice C's part the next release of A or B transfers to `SINK` (including unrecognized balance). function sinkClaimable() external view returns (uint256) { return previewAccrual().sink - sinkPaid; } /// @notice Full ledger snapshot; see docs/ACCOUNTING.md for the identities. function reconciliation() external view returns (Reconciliation memory r) { r.bemBalance = bem.balanceOf(address(this)); r.totalAccounted = totalAccounted; r.totalPaid = totalPaid; uint256 expected = totalAccounted - totalPaid; if (r.bemBalance >= expected) r.unrecognized = r.bemBalance - expected; else r.deficit = expected - r.bemBalance; PoolMath.Accrual memory a = _accrual(totalAccounted); r.recipientAAccrued = a.recipientA; r.recipientAPaid = recipientAPaid; r.recipientBAccrued = a.recipientB; r.recipientBPaid = recipientBPaid; r.sinkAccrued = a.sink; r.sinkPaid = sinkPaid; r.nandIncome = a.nandIncome; r.nandReceiptsAccrued = a.nandReceipts; r.nandReceiptsPaid = _receiptsPaid[0]; r.latchIncome = a.latchIncome; r.latchReceiptsAccrued = a.latchReceipts; r.latchReceiptsPaid = _receiptsPaid[1]; r.residue = a.residue; } function minerCount() external view returns (uint256) { return _minerIds.length; } function miner(uint256 tokenId) external view returns (Miner memory) { return _miners[tokenId]; } function minerAt(uint256 index) external view returns (uint256 tokenId, bytes32 key) { return (_minerIds[index], _minerKeys[index]); } function minersPage(uint256 offset, uint256 limit) external view returns (uint256[] memory tokenIds, bytes32[] memory keys) { uint256 length = _minerIds.length; if (limit == 0 || limit > MAX_PAGE || offset > length) revert InvalidPage(); uint256 count = limit > length - offset ? length - offset : limit; tokenIds = new uint256[](count); keys = new bytes32[](count); for (uint256 i; i < count; ++i) { tokenIds[i] = _minerIds[offset + i]; keys[i] = _minerKeys[offset + i]; } } // --------------------------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------------------------- function _requireActive() private view { if (!active) revert NotActive(); } function _admit(uint256[] calldata tokenIds, bool pull) private returns (uint256 settled, uint256 carried) { _requireActive(); if (!isDepositor[msg.sender]) revert NotDepositor(msg.sender); uint256 n = tokenIds.length; if (n == 0 || n > MAX_ADMISSION_BATCH) revert InvalidBatch(); if (!custodyOpen) _openCustody(); // 1. Validate the batch and read each miner's authoritative burn from Mining. bytes32[] memory keys = new bytes32[](n); uint32[] memory nands = new uint32[](n); uint32[] memory latches = new uint32[](n); uint256 addNand; uint256 addLatch; for (uint256 i; i < n; ++i) { uint256 id = tokenIds[i]; for (uint256 j; j < i; ++j) { if (tokenIds[j] == id) revert DuplicateMiner(id); } if (_miners[id].registered) revert MinerAlreadyRegistered(id); address holder = minerCollection.ownerOf(id); if (holder != (pull ? msg.sender : address(this))) revert MinerNotHeld(id, holder); (keys[i], nands[i], latches[i]) = _readComposition(id); addNand += nands[i]; addLatch += latches[i]; } // A category without receipts cannot receive income; refuse instead of redirecting it. if (addNand != 0 && nandShares == 0) revert UnsupportedCategory(Category.NAND); if (addLatch != 0 && latchShares == 0) revert UnsupportedCategory(Category.LATCH); // 2. BEM already held belongs to the old composition. _sync(); // 3. Collect everything the registered miners can pay now, under the old composition. uint256 oldCount = _minerKeys.length; for (uint256 i; i < oldCount; ++i) { _settleOld(i); } settled = _sync(); // 4. Close the old period (its category entitlements are frozen) and take custody. _checkpoint(); uint256 period = compositionPeriod; for (uint256 i; i < n; ++i) { uint256 id = tokenIds[i]; if (pull) minerCollection.transferFrom(msg.sender, address(this), id); if (minerCollection.ownerOf(id) != address(this)) { revert MinerNotHeld(id, minerCollection.ownerOf(id)); } _miners[id] = Miner({ key: keys[i], nandBurn: nands[i], latchBurn: latches[i], admittedInPeriod: uint64(period), registered: true, carriedIncome: 0 }); _minerIds.push(id); _minerKeys.push(keys[i]); emit MinerRegistered(id, keys[i], msg.sender, nands[i], latches[i]); } // 5. Income each incoming miner carried in belongs to the pool, split by that miner's burn. No // `_sync` runs before it is attributed, so the pool's composition never splits it. carried = _collectCarried(tokenIds, keys, nands, latches); // 6. The new composition prices everything recognized from now on. totalNandBurn += addNand; totalLatchBurn += addLatch; uint256 newPeriodIncome = _sync(); emit MinersAdmitted( msg.sender, period, n, settled, carried, newPeriodIncome, totalNandBurn, totalLatchBurn ); } /// @dev Claims the incoming miners one by one (each claim must succeed) and attributes the exact /// balance increase of each claim to that miner: its participant part goes to NAND and LATCH /// by its own burn, independent of the pool's composition and of the batch's order. The carried /// total is recognized at the boundary, so the new period starts after it. function _collectCarried( uint256[] calldata tokenIds, bytes32[] memory keys, uint32[] memory nands, uint32[] memory latches ) private returns (uint256 carried) { uint256 toNandTotal; uint256 toLatchTotal; uint256 balance = bem.balanceOf(address(this)); for (uint256 i; i < tokenIds.length; ++i) { mining.claim(keys[i]); uint256 afterClaim = bem.balanceOf(address(this)); if (afterClaim < balance) revert BalanceDecreased(afterClaim, balance); uint256 received = afterClaim - balance; if (received > type(uint120).max) revert UnexpectedTokenBehavior(); balance = afterClaim; (uint256 toNand, uint256 toLatch) = PoolMath.carriedUnits(received, nands[i], latches[i]); _miners[tokenIds[i]].carriedIncome = uint120(received); carried += received; toNandTotal += toNand; toLatchTotal += toLatch; emit CarriedIncomeAttributed(tokenIds[i], received, toNand, toLatch); } if (carried == 0) return 0; // The checkpoint just set accountedBase = totalAccounted, and nothing was recognized since. totalAccounted += carried; accountedBase = totalAccounted; nandBaseUnits += toNandTotal; latchBaseUnits += toLatchTotal; carriedIncome += carried; carriedNandUnits += toNandTotal; emit IncomeRecognized(carried, totalAccounted, compositionPeriod); } /// @dev Custody begins only when no privileged role of the initialization remains. Activation (which /// every admission requires) already established, irreversibly, that both issuances are finalized /// with supplies equal to the denominators and that the bound distributor's allocation is /// finalized. This adds that this pool, both receipt collections and the distributor have /// renounced ownership. A renounced owner cannot return, so only the first admission checks. /// Nothing here renounces anything on another contract's behalf. function _openCustody() private { if (owner() != address(0)) revert OwnershipNotRenounced(address(this)); if (nandReceipt.owner() != address(0)) revert OwnershipNotRenounced(address(nandReceipt)); if (latchReceipt.owner() != address(0)) revert OwnershipNotRenounced(address(latchReceipt)); if (IReceiptDistributor(distributor).owner() != address(0)) { revert OwnershipNotRenounced(distributor); } custodyOpen = true; emit CustodyOpened(distributor, nandShares, latchShares); } /// @dev Reads the miner record and cross-checks it with the burn cache: both must agree, the miner /// must be ACTIVE and belong to this collection/token, and it must have burned something. function _readComposition(uint256 tokenId) private view returns (bytes32 key, uint32 nand, uint32 latch) { key = mining.minerKey(address(minerCollection), tokenId); IMining.MinerRecord memory r = mining.getMiner(key); if ( r.status != MINER_ACTIVE || r.circuits != address(minerCollection) || uint256(r.circuitId) != tokenId ) { revert MinerNotActive(tokenId); } (bool cached, uint32 cachedNand, uint32 cachedLatch) = mining.cachedBurn(address(minerCollection), tokenId); if (!cached || cachedNand != r.nandBurn || cachedLatch != r.latchBurn) { revert CompositionMismatch(tokenId); } if (uint256(r.nandBurn) + r.latchBurn == 0) revert EmptyComposition(tokenId); return (key, r.nandBurn, r.latchBurn); } /// @dev Strict settlement of one registered miner: any failed claim aborts the admission, with one /// exception. A miner Mining reports as REVOKED is permanent and Mining refuses every claim for /// it, so nothing of it can ever be collected; it is skipped with an event. A pool-held miner /// cannot be stopped (only the NFT holder can call Mining.stop, and the pool never does), so a /// COOLING or never-started status here is unexpected and fails closed like any other failure. function _settleOld(uint256 index) private { bytes32 key = _minerKeys[index]; try mining.claim(key) {} catch (bytes memory reason) { if (mining.getMiner(key).status != MINER_REVOKED) { revert SettlementFailed(_minerIds[index], reason); } emit RevokedMinerSkipped(_minerIds[index]); } } /// @dev Freezes the category entitlements reached under the old composition, fractions included. function _checkpoint() private { (uint256 nandUnits, uint256 latchUnits) = PoolMath.categoryUnits(totalAccounted, ledgerState()); accountedBase = totalAccounted; nandBaseUnits = nandUnits; latchBaseUnits = latchUnits; ++compositionPeriod; emit CompositionCheckpoint(compositionPeriod, totalAccounted, nandUnits, latchUnits); } function _harvestOne(uint256 tokenId, bytes32 key, uint256 beforeBalance) private returns (bool ok, uint256 balance) { try mining.claim(key) { balance = bem.balanceOf(address(this)); emit MinerHarvested(tokenId, balance > beforeBalance ? balance - beforeBalance : 0); ok = true; } catch (bytes memory reason) { balance = beforeBalance; emit MinerHarvestFailed(tokenId, reason); } } function _claimMany(Category category, uint256[] calldata ids, uint256 perShare) private returns (uint256 total) { if (ids.length == 0) return 0; uint256 limit = shareCount(category); for (uint256 i; i < ids.length; ++i) { total += _claimOne(category, ids[i], perShare, limit); } _receiptsPaid[uint256(category)] += total; } function _claimOne(Category category, uint256 id, uint256 perShare, uint256 limit) private returns (uint256 due) { if (id == 0 || id > limit) revert ReceiptIdOutOfRange(category, id); // Only the current holder can claim; ERC-1155 operators must transfer the ID first. if (receiptOf(category).balanceOf(msg.sender, id) != 1) revert NotHolder(category, id); mapping(uint256 => uint256) storage history = _claimed[category]; due = perShare - history[id]; history[id] = perShare; emit ReceiptIncomeClaimed(category, id, msg.sender, due); } /// @dev One snapshot of the recognized total for both payments. The selected role receives its own /// unpaid part; C receives all of its unpaid part (`sink - sinkPaid`), never a multiple of this /// call's role payment, since A and B release at different times and round independently. function _release(Recipient role) private returns (uint256 amount) { _requireActive(); _sync(); PoolMath.Accrual memory a = _accrual(totalAccounted); address to; if (role == Recipient.A) { amount = a.recipientA - recipientAPaid; recipientAPaid = a.recipientA; to = recipientA; } else { amount = a.recipientB - recipientBPaid; recipientBPaid = a.recipientB; to = recipientB; } uint256 sinkAmount = a.sink - sinkPaid; sinkPaid = a.sink; _payWithSink(to, amount, sinkAmount); emit RecipientPaid(role, to, amount); emit SinkPaid(role, SINK, sinkAmount); } function _accrual(uint256 total) private view returns (PoolMath.Accrual memory) { return PoolMath.accrue(total, ledgerState()); } /// @dev Recognize BEM that arrived since the last sync as ordinary income of the current period. /// Nothing is recognized while no miner has been admitted (there is no composition to split it /// by); the first admission recognizes it after its carried income, under the first composition. function _sync() private returns (uint256 received) { if (totalNandBurn + totalLatchBurn == 0) return 0; uint256 balance = bem.balanceOf(address(this)); uint256 expected = totalAccounted - totalPaid; if (balance < expected) revert BalanceDecreased(balance, expected); received = balance - expected; if (received != 0) { totalAccounted += received; emit IncomeRecognized(received, totalAccounted, compositionPeriod); } } /// @dev A release's two payments as one unit: every counter (the role's and C's paid amounts, set by /// the caller, and `totalPaid` here) changes before either transfer, and both transfers and all /// three balance deltas must be exact, or the whole release reverts. `to` is never `SINK` or this /// pool (both refused for A and B). function _payWithSink(address to, uint256 amount, uint256 sinkAmount) private { uint256 outflow = amount + sinkAmount; if (outflow == 0) return; uint256 selfBefore = bem.balanceOf(address(this)); uint256 toBefore = bem.balanceOf(to); uint256 sinkBefore = bem.balanceOf(SINK); totalPaid += outflow; if (amount != 0) bem.safeTransfer(to, amount); if (sinkAmount != 0) bem.safeTransfer(SINK, sinkAmount); if ( bem.balanceOf(address(this)) != selfBefore - outflow || bem.balanceOf(to) != toBefore + amount || bem.balanceOf(SINK) != sinkBefore + sinkAmount ) revert UnexpectedTokenBehavior(); } /// @dev Plain, non-rebasing, fee-free BEM only: both balance deltas must be exact. function _pay(address to, uint256 amount) private { if (amount == 0) return; uint256 selfBefore = bem.balanceOf(address(this)); uint256 toBefore = bem.balanceOf(to); totalPaid += amount; bem.safeTransfer(to, amount); if (bem.balanceOf(address(this)) != selfBefore - amount || bem.balanceOf(to) != toBefore + amount) { revert UnexpectedTokenBehavior(); } } }