Miner custody & BEM distribution pool
Holds miner NFTs, collects their BEM through Mining.claim, and distributes what it actually receives. Each processor deploys it twice, as the Optimal Pool and the Regular Pool; both pay the same NAND and LATCH receipts and bind the processor's one distributor.
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.
- Income the admitted miners earn in the pool is divided between NAND and LATCH by the materials burned by all miners in this pool, N/(N+L). Income a miner carries in, earned before its admission, is divided by that miner's own burn. Each category is then shared equally per receipt ID.
- Both kinds of income enter a fractional category ledger (1/(84 × 10^18) of the smallest BEM unit), which replaces 2.1.0's whole-unit checkpoints. Payouts round down.
- 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.
- Setup: the owner binds the processor's distributor, once, which must count this pool as one of its two pools and report these receipts; then activates once the receipt issuances are finalized with the expected supplies and the distributor's allocation is finalized.
- The first miner admission also requires the ownership of this pool, both receipts and the distributor to be renounced; it then sets custodyOpen. Activation alone does not open custody. The shared receipts and distributor gate both pools; this pool's own ownership gates only this pool.
- 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: bindDistributor, once, before activation; 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, and custody cannot open until that ownership is renounced. The distributor, depositor whitelist and protocol addresses cannot be changed; the split weights and the sink address are code constants.
Mining lifecycle
Order independence and its assumptions
Rounding qualifications
What the finality gates do not prove
Admission capacity
Source code
tapeminecoop/groupmine @ 8819a59
src/TapeoutMinerPool.sol
915 lines42.7 KBSolidity 0.8.24MIT licensesha256 5cb9e6bd8cca…
// 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();
}
}
}src/PoolMath.sol
120 lines5.9 KBSolidity 0.8.24MIT licensesha256 4b9b53b542d6…
// 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).
///
/// Category ledger. The participants' entitlement is kept in ledger units of 1 / LEDGER_UNIT raw
/// BEM (LEDGER_UNIT = 84 * 1e18), where it is exact: T raw units are T * 80 * 1e18 ledger units.
/// The NAND and LATCH entitlements are kept in the same units and always add up to that total, so
/// no fraction is ever dropped at a boundary. Income enters the ledger in one of two ways:
/// - ordinary income, recognized while the pool's composition is (N, L), is split by N : L;
/// - carried income of an incoming miner (what its own Mining.claim paid during its admission)
/// is split by that miner's own burn n : l, whatever the pool's composition is.
/// In both cases NAND takes floor(units * n / (n + l)) and LATCH the rest, so a split loses
/// nothing and favours LATCH by less than one ledger unit. A carried split depends only on the
/// miner's (received, n, l), so admitting the same miners in any order, in one batch or in
/// several, gives the same ledger. Admissions are boundaries: `accountedBase` is T at the last
/// boundary and `nandBase` / `latchBase` the category entitlements there; inside a period
/// d = (T - accountedBase) * 80 * 1e18
/// nandUnits = nandBase + floor(d * N / (N + L))
/// latchUnits = latchBase + d - floor(d * N / (N + L))
/// and nandBase + latchBase == accountedBase * 80 * 1e18 at every boundary. Category incomes are
/// floor(units / LEDGER_UNIT); together they are participants or participants - 1. Per-share values
/// are floor(categoryIncome / S) with S fixed at activation. Every quantity is non-decreasing in T,
/// never decreases at a boundary, and within one period 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;
/// @dev Ledger units per raw BEM unit, divided by TOTAL_PARTS.
uint256 internal constant LEDGER_PRECISION = 1e18;
/// @dev Ledger units per raw BEM unit.
uint256 internal constant LEDGER_UNIT = TOTAL_PARTS * LEDGER_PRECISION;
struct State {
uint256 accountedBase;
uint256 nandBase;
uint256 latchBase;
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;
(uint256 nandUnits, uint256 latchUnits) = categoryUnits(total, s);
a.nandIncome = nandUnits / LEDGER_UNIT;
a.latchIncome = latchUnits / LEDGER_UNIT;
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;
}
/// @notice Category entitlements at `total` in ledger units: the boundary values plus the current
/// period's ordinary income split by the pool's composition. Without a composition (only
/// before the first admission, when nothing is recognized) nothing is split.
function categoryUnits(uint256 total, State memory s)
internal
pure
returns (uint256 nandUnits, uint256 latchUnits)
{
nandUnits = s.nandBase;
latchUnits = s.latchBase;
uint256 weight = s.totalNandBurn + s.totalLatchBurn;
if (weight != 0) {
uint256 d = participantUnits(total - s.accountedBase);
uint256 toNand = d * s.totalNandBurn / weight;
nandUnits += toNand;
latchUnits += d - toNand;
}
}
/// @notice An incoming miner's carried income split by its own burn (`nand` : `latch`).
function carriedUnits(uint256 received, uint256 nand, uint256 latch)
internal
pure
returns (uint256 toNand, uint256 toLatch)
{
uint256 units = participantUnits(received);
toNand = units * nand / (nand + latch);
toLatch = units - toNand;
}
/// @notice The participants' exact share of `amount` raw BEM, in ledger units.
function participantUnits(uint256 amount) internal pure returns (uint256) {
return amount * PARTICIPANT_PARTS * LEDGER_PRECISION;
}
}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;
}src/interfaces/IReceiptDistributor.sol
17 lines0.6 KBSolidity 0.8.24MIT licensesha256 7e02dc0d828a…
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
/// @notice The views of TapeoutReceiptDistributor that a custody pool reads: its binding when the
/// owner binds it before activation (the pool must be one of the distributor's two fixed pools),
/// and its finality when custody begins.
interface IReceiptDistributor {
function isPool(address candidate) external view returns (bool);
function nandReceipt() external view returns (address);
function latchReceipt() external view returns (address);
function allocationFinalized() external view returns (bool);
function owner() external view returns (address);
}