04 · TapeoutMinerPool
Miner custody & BEM distribution pool
Holds miner NFTs, collects their BEM through Mining.claim, and distributes what it actually receives. Each processor is intended to deploy it twice, as the Optimal Pool and the Regular Pool, both paying the same NAND and LATCH receipts.
What it does
- BEM the pool receives is split by the fixed weights 100 : 1.25 : 1.25 : 2.5 (80 : 1 : 1 : 2 of 84 on chain) between receipt holders, recipient A, recipient B and the sink address 0x…dEaD.
- Every release of recipient A or B also transfers the sink's whole unpaid share to 0x…dEaD in the same transaction. It is an ordinary BEM transfer, not a token-contract burn.
- The receipt share is divided between NAND and LATCH by the materials actually burned by the miners admitted to this pool, N/(N+L), then equally per receipt ID. Each pool uses only its own miners' materials.
- Each pool keeps its own claim history, so a receipt claims its share from each pool separately; no function claims from two pools at once.
- Miners are admitted only by a fixed depositor whitelist, through an atomic settlement boundary. Anyone can harvest; holders claim per receipt ID, and unclaimed BEM follows the receipt.
- There is no pause, upgrade, miner withdrawal, BEM sweep or arbitrary call path.
- Privileged role
- Owner: activate, once, which fixes the receipt supplies as permanent denominators. Recipients A and B can each move only their own role.
- After finalization
- After activation the owner has no remaining power. The depositor whitelist and protocol addresses are fixed at deployment; the split weights and the sink address are code constants.
Pool Rules: how the Optimal and Regular Pools share income, in plain language
Mining lifecycle
Rounding across admissions
Source code
tapeminecoop/groupmine @ 00e37c7
src/TapeoutMinerPool.sol
791 lines34.5 KBSolidity 0.8.24MIT licensesha256 6a2d0c34b42f…
// 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 {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, split by the
/// aggregate NAND/LATCH burn of the admitted miners 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
/// `activate` (receipt supplies finalized and fixed as denominators) -> whitelisted admissions,
/// harvests, claims and releases. After activation the owner has no remaining power, so
/// renouncing ownership does not affect operation. 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, take custody, add the batch to the composition totals;
/// 5. claim the incoming miners' carried income and recognize it under the new composition.
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;
}
/// @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.1.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 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;
bool public active;
uint64 public activatedAt;
uint256 public nandShares;
uint256 public latchShares;
uint256 public totalNandBurn;
uint256 public totalLatchBurn;
uint256 public compositionPeriod;
uint256 public nandBase;
uint256 public latchBase;
uint256 public participantBase;
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();
event Deployed(
address nandReceipt,
address latchReceipt,
address recipientA,
address recipientB,
address[] depositors
);
event Activated(uint256 nandShares, uint256 latchShares, uint256 activatedAt);
event IncomeRecognized(uint256 received, uint256 totalAccounted, uint256 compositionPeriod);
event CompositionCheckpoint(
uint256 indexed period,
uint256 totalAccounted,
uint256 nandBase,
uint256 latchBase,
uint256 participantBase
);
event MinerRegistered(
uint256 indexed tokenId,
bytes32 indexed key,
address indexed depositor,
uint256 nandBurn,
uint256 latchBurn
);
event RevokedMinerSkipped(uint256 indexed tokenId);
event MinersAdmitted(
address indexed depositor,
uint256 indexed period,
uint256 count,
uint256 settledOldIncome,
uint256 carriedIncome,
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);
}
// ---------------------------------------------------------------------------------------------
// Activation (owner, once)
// ---------------------------------------------------------------------------------------------
/// @notice Fix both category denominators. Both receipt collections must have finalized issuance,
/// and the supplies must equal the values the operator reviewed.
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();
nandShares = sN;
latchShares = sL;
active = true;
activatedAt = uint64(block.timestamp);
emit Activated(sN, sL, block.timestamp);
}
// ---------------------------------------------------------------------------------------------
// Admission (whitelisted depositors, ACTIVE)
// ---------------------------------------------------------------------------------------------
/// @notice Admit miners owned by the caller (requires ERC-721 approval to this pool).
function depositMiners(uint256[] calldata tokenIds)
external
nonReentrant
returns (uint256 settledOldIncome, uint256 carriedIncome)
{
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.
function registerMiners(uint256[] calldata tokenIds)
external
nonReentrant
returns (uint256 settledOldIncome, uint256 carriedIncome)
{
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;
}
function state() public view returns (PoolMath.State memory) {
return PoolMath.State({
nandBase: nandBase,
latchBase: latchBase,
participantBase: participantBase,
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();
// 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, take custody and switch to the post-batch composition.
_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
});
_minerIds.push(id);
_minerKeys.push(keys[i]);
emit MinerRegistered(id, keys[i], msg.sender, nands[i], latches[i]);
}
totalNandBurn += addNand;
totalLatchBurn += addLatch;
// 5. Income the incoming miners carried in belongs to the pool, under the new composition.
for (uint256 i; i < n; ++i) {
mining.claim(keys[i]);
}
carried = _sync();
emit MinersAdmitted(msg.sender, period, n, settled, carried, totalNandBurn, totalLatchBurn);
}
/// @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]);
}
}
function _checkpoint() private {
PoolMath.Accrual memory a = _accrual(totalAccounted);
nandBase = a.nandIncome;
latchBase = a.latchIncome;
participantBase = a.nandIncome + a.latchIncome;
++compositionPeriod;
emit CompositionCheckpoint(compositionPeriod, totalAccounted, nandBase, latchBase, participantBase);
}
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, state());
}
/// @dev Recognize BEM that arrived since the last sync. Nothing is recognized while no miner has
/// been admitted (there is no composition to split it by); it is recognized at the first admission.
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();
}
}
}src/PoolMath.sol
77 lines3.6 KBSolidity 0.8.24MIT licensesha256 c7ab82e43c2a…
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
/// @notice Allocation math for the pool's cumulative income (mirrored exactly by sdk/math.ts).
/// @dev Outer split of the cumulative recognized total T. The relative weights NAND + LATCH 100 :
/// A 1.25 : B 1.25 : C 2.5 (105 displayed parts) are held as the equal integer ratio
/// 80 : 1 : 1 : 2 (84 parts):
/// recipientA = floor(T * 1 / 84)
/// recipientB = floor(T * 1 / 84)
/// sink = floor(T * 2 / 84) (C, paid to the pool's fixed sink address)
/// participants = floor(T * 80 / 84)
/// Each is computed from T on its own, never as T minus other rounded parts, so each is
/// non-decreasing in T. With r = T mod 84 the four floors leave r - floor(2r/84) - floor(80r/84)
/// raw units unallocated: at most 3 (for r = 64 ... 83).
/// Participant income is split between NAND and LATCH by the aggregate burn composition (N, L) of
/// the admitted miners. The composition changes only at admissions, so time is divided into
/// composition periods. At each admission the category totals are checkpointed (`nandBase`,
/// `latchBase`) together with `participantBase = nandBase + latchBase`; inside a period
/// d = participants - participantBase
/// nandIncome = nandBase + floor(d * N / (N + L))
/// latchIncome = latchBase + floor(d * L / (N + L))
/// so income recognized in an earlier period is never re-split with a later ratio. The at most one
/// raw unit that the two floors leave unallocated is carried into the next period's `d`.
/// Per-share values are floor(categoryIncome / S) with S fixed at activation. Every quantity is
/// non-decreasing in T, and within one period it depends only on T (path independent).
library PoolMath {
uint256 internal constant TOTAL_PARTS = 84;
uint256 internal constant PARTICIPANT_PARTS = 80;
uint256 internal constant RECIPIENT_PARTS = 1;
uint256 internal constant SINK_PARTS = 2;
struct State {
uint256 nandBase;
uint256 latchBase;
uint256 participantBase;
uint256 totalNandBurn;
uint256 totalLatchBurn;
uint256 nandShares;
uint256 latchShares;
}
struct Accrual {
uint256 total;
uint256 recipientA;
uint256 recipientB;
uint256 sink;
uint256 participants;
uint256 nandIncome;
uint256 latchIncome;
uint256 perNandShare;
uint256 perLatchShare;
uint256 nandReceipts;
uint256 latchReceipts;
uint256 residue;
}
function accrue(uint256 total, State memory s) internal pure returns (Accrual memory a) {
a.total = total;
a.recipientA = total * RECIPIENT_PARTS / TOTAL_PARTS;
a.recipientB = a.recipientA;
a.sink = total * SINK_PARTS / TOTAL_PARTS;
a.participants = total * PARTICIPANT_PARTS / TOTAL_PARTS;
a.nandIncome = s.nandBase;
a.latchIncome = s.latchBase;
uint256 weight = s.totalNandBurn + s.totalLatchBurn;
if (weight != 0) {
uint256 d = a.participants - s.participantBase;
a.nandIncome += d * s.totalNandBurn / weight;
a.latchIncome += d * s.totalLatchBurn / weight;
}
if (s.nandShares != 0) a.perNandShare = a.nandIncome / s.nandShares;
if (s.latchShares != 0) a.perLatchShare = a.latchIncome / s.latchShares;
a.nandReceipts = s.nandShares * a.perNandShare;
a.latchReceipts = s.latchShares * a.perLatchShare;
a.residue = total - a.recipientA - a.recipientB - a.sink - a.nandReceipts - a.latchReceipts;
}
}src/interfaces/IMining.sol
53 lines2.1 KBSolidity 0.8.24MIT licensesha256 c8d8c41b57f5…
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
/// @notice Subset of the deployed TapeOut Mining ABI used by the pool.
/// @dev Function signatures and the miner record layout come from the official TapeOut frontend ABI
/// (tapeout.net/pod, bundle sha256 0f1b12c3…a1e) and were checked against raw return data of the
/// deployed implementation (docs/VERIFICATION_REPORT.md). Observed behaviour the pool relies on:
/// - `claim(key)` is callable by anyone, pays the miner NFT's current holder, can pay while
/// `pending(key)` is zero, and reverts (0x5f9bb3be) unless the miner's status is ACTIVE (1);
/// - `claimMany(keys)` silently skips keys that cannot be claimed, so the pool never uses it
/// where every claim must succeed;
/// - `nandBurn`/`latchBurn` are the circuit's local actual burn (NAND/LATCH consumed), equal to
/// `cachedBurn` for every active miner sampled; `gateCount`, `area`, `bstar`, `mult`, `bonus`
/// and the weights are circuit metrics or mining power, not consumed materials.
interface IMining {
struct MinerRecord {
address circuits;
uint64 circuitId;
uint32 taskId;
uint32 gateCount;
uint32 stateCount;
uint32 depth;
uint64 area;
uint32 mult;
uint64 since;
uint8 status;
address registrant;
uint32 nandBurn;
uint32 latchBurn;
uint64 bstar;
uint64 bonus;
bool optimal;
uint64 commitBlock;
uint64 firstUnusedId;
uint64 stopBlock;
uint128 verifWeight;
uint128 unverWeight;
uint256 debt;
}
function minerKey(address collection, uint256 tokenId) external view returns (bytes32);
function getMiner(bytes32 key) external view returns (MinerRecord memory);
function cachedBurn(address circuits, uint256 circuitId)
external
view
returns (bool cached, uint32 nand, uint32 latch);
function pending(bytes32 key) external view returns (uint256);
function claim(bytes32 key) external;
}