03 · TapeoutReceiptDistributor
Receipt distributor
Holds the NAND and LATCH receipts until each allocated wallet claims its own. The allocation is recorded on-chain as ID ranges per wallet.
What it does
- Bound to one custody pool, from which it reads the two receipt addresses. It refuses receipts that already have BEM claim history in that pool; with two pools sharing the receipts, the other pool's history is not checked.
- The owner records allocations (allocate), can correct a wallet (revokeWallet), and finalizes once every receipt in inventory is allocated.
- A beneficiary calls claim, or anyone calls deliver for them; receipts always go to the recorded wallet. There is no deadline.
- Never sends a transaction to a pool and never approves an operator: it can only move a receipt to its beneficiary.
- Privileged role
- Owner: allocate, revokeWallet, finalizeAllocation, all before finalization only.
- After finalization
- Nothing. Beneficiaries and ID ranges are immutable.
Source code
tapeminecoop/groupmine @ 00e37c7
src/TapeoutReceiptDistributor.sol
300 lines13.8 KBSolidity 0.8.24MIT licensesha256 67f0b24fa547…
// 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 {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {TapeoutEntitlementReceipt} from "./TapeoutEntitlementReceipt.sol";
import {TapeoutMinerPool} from "./TapeoutMinerPool.sol";
/// @notice Holds NAND and LATCH receipts until each allocated wallet claims its own. The operator
/// funds it with receipts, imports the allocation (tooling turns the JSON wallet counts into
/// ID ranges), and finalizes; afterwards beneficiaries and allocations are immutable.
/// @dev While held here, receipts keep their place in the pool's accounting: nobody can claim their
/// BEM (only a receipt's holder can, and this contract never calls the pool), approve them,
/// or move them anywhere except to their recorded beneficiary. Receipts that already have BEM
/// claim history are refused as inventory, and nothing can be received after finalization, so
/// previously distributed receipts can never be redistributed.
contract TapeoutReceiptDistributor is Ownable2Step, ReentrancyGuard, IERC1155Receiver {
struct Range {
TapeoutMinerPool.Category category;
uint64 firstId;
uint64 count;
uint64 delivered;
}
struct AllocationInput {
TapeoutMinerPool.Category category;
address wallet;
uint64 firstId;
uint64 count;
}
uint256 public constant MAX_RANGE = 1000;
uint256 public constant MAX_PAGE = 500;
TapeoutMinerPool public immutable pool;
TapeoutEntitlementReceipt public immutable nandReceipt;
TapeoutEntitlementReceipt public immutable latchReceipt;
bool public allocationFinalized;
uint256[2] private _inventory;
uint256[2] private _allocated;
uint256[2] private _delivered;
mapping(TapeoutMinerPool.Category => mapping(uint256 id => address wallet)) private _beneficiary;
mapping(address wallet => Range[]) private _ranges;
/// @dev Index of the wallet's first range with undelivered IDs; earlier ranges are complete.
mapping(address wallet => uint256 index) private _nextRange;
address[] private _wallets;
mapping(address wallet => bool) private _listed;
error InvalidAddress();
error UnsupportedToken(address token);
error AllocationClosed();
error AllocationOpen();
error IssuanceNotFinalized();
error InvalidAllocation();
error NotInInventory(TapeoutMinerPool.Category category, uint256 id);
error AlreadyAllocated(TapeoutMinerPool.Category category, uint256 id, address wallet);
error InventoryNotCovered(TapeoutMinerPool.Category category, uint256 allocated, uint256 inventory);
error InvalidAmount(uint256 id, uint256 value);
error ConsumedRewardHistory(TapeoutMinerPool.Category category, uint256 id);
error NothingToDeliver(address wallet);
error InvalidPage();
event InventoryReceived(TapeoutMinerPool.Category indexed category, address indexed from, uint256 count);
event Allocated(
address indexed wallet, TapeoutMinerPool.Category indexed category, uint256 firstId, uint256 count
);
event WalletRevoked(address indexed wallet, uint256 nandCount, uint256 latchCount);
event AllocationFinalized(uint256 nandCount, uint256 latchCount, uint256 wallets);
event Delivered(
address indexed wallet, TapeoutMinerPool.Category indexed category, uint256 firstId, uint256 count
);
constructor(address owner_, TapeoutMinerPool pool_) Ownable(owner_) {
if (address(pool_).code.length == 0) revert InvalidAddress();
pool = pool_;
nandReceipt = pool_.nandReceipt();
latchReceipt = pool_.latchReceipt();
}
// ---------------------------------------------------------------------------------- funding
function onERC1155Received(address, address from, uint256 id, uint256 value, bytes calldata)
external
returns (bytes4)
{
TapeoutMinerPool.Category category = _categoryOf(msg.sender);
if (allocationFinalized) revert AllocationClosed();
if (_acceptOne(category, id, value)) {
++_inventory[uint256(category)];
emit InventoryReceived(category, from, 1);
}
return IERC1155Receiver.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata
) external returns (bytes4) {
TapeoutMinerPool.Category category = _categoryOf(msg.sender);
if (allocationFinalized) revert AllocationClosed();
uint256 accepted;
for (uint256 i; i < ids.length; ++i) {
if (_acceptOne(category, ids[i], values[i])) ++accepted;
}
_inventory[uint256(category)] += accepted;
emit InventoryReceived(category, from, accepted);
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId || interfaceId == type(IERC165).interfaceId;
}
// ---------------------------------------------------------------------------------- allocation
/// @notice Record beneficiaries for ranges of IDs held as inventory. Each ID once.
function allocate(AllocationInput[] calldata entries) external onlyOwner {
if (allocationFinalized) revert AllocationClosed();
if (entries.length == 0) revert InvalidAllocation();
for (uint256 e; e < entries.length; ++e) {
AllocationInput calldata a = entries[e];
if (
a.wallet == address(0) || a.wallet == address(this) || a.wallet == address(pool)
|| a.count == 0 || a.count > MAX_RANGE || a.firstId == 0
) revert InvalidAllocation();
address[] memory holders = _receipt(a.category).holdersPage(a.firstId, a.count);
mapping(uint256 => address) storage beneficiary = _beneficiary[a.category];
for (uint256 i; i < a.count; ++i) {
uint256 id = uint256(a.firstId) + i;
if (holders[i] != address(this)) revert NotInInventory(a.category, id);
if (beneficiary[id] != address(0)) revert AlreadyAllocated(a.category, id, beneficiary[id]);
beneficiary[id] = a.wallet;
}
if (!_listed[a.wallet]) {
_listed[a.wallet] = true;
_wallets.push(a.wallet);
}
_ranges[a.wallet]
.push(Range({category: a.category, firstId: a.firstId, count: a.count, delivered: 0}));
_allocated[uint256(a.category)] += a.count;
emit Allocated(a.wallet, a.category, a.firstId, a.count);
}
}
/// @notice Before finalization, remove every range allocated to `wallet` (to correct an import).
function revokeWallet(address wallet) external onlyOwner {
if (allocationFinalized) revert AllocationClosed();
Range[] storage list = _ranges[wallet];
uint256[2] memory removed;
for (uint256 r; r < list.length; ++r) {
Range storage range = list[r];
mapping(uint256 => address) storage beneficiary = _beneficiary[range.category];
for (uint256 i; i < range.count; ++i) {
delete beneficiary[uint256(range.firstId) + i];
}
removed[uint256(range.category)] += range.count;
}
_allocated[0] -= removed[0];
_allocated[1] -= removed[1];
delete _ranges[wallet];
emit WalletRevoked(wallet, removed[0], removed[1]);
}
/// @notice Freeze the allocation and open claims. Every held receipt must be allocated, and both
/// receipt collections must have finalized issuance.
function finalizeAllocation() external onlyOwner {
if (allocationFinalized) revert AllocationClosed();
if (!nandReceipt.issuanceFinalized() || !latchReceipt.issuanceFinalized()) {
revert IssuanceNotFinalized();
}
for (uint256 c; c < 2; ++c) {
if (_allocated[c] != _inventory[c]) {
revert InventoryNotCovered(TapeoutMinerPool.Category(c), _allocated[c], _inventory[c]);
}
}
allocationFinalized = true;
emit AllocationFinalized(_inventory[0], _inventory[1], _wallets.length);
}
// ---------------------------------------------------------------------------------- delivery
/// @notice Deliver up to `maxIds` of the caller's allocated receipts to the caller.
function claim(uint256 maxIds) external nonReentrant returns (uint256 delivered) {
return _deliver(msg.sender, maxIds);
}
/// @notice Push up to `maxIds` of `wallet`'s allocated receipts to `wallet` itself. Anyone may call;
/// the destination is always the recorded beneficiary.
function deliver(address wallet, uint256 maxIds) external nonReentrant returns (uint256 delivered) {
return _deliver(wallet, maxIds);
}
// ---------------------------------------------------------------------------------- views
function beneficiaryOf(TapeoutMinerPool.Category category, uint256 id) external view returns (address) {
return _beneficiary[category][id];
}
function rangesOf(address wallet) external view returns (Range[] memory) {
return _ranges[wallet];
}
/// @notice Allocated and still undelivered IDs of `wallet`, per category.
function pendingOf(address wallet) external view returns (uint256 nandCount, uint256 latchCount) {
Range[] storage list = _ranges[wallet];
for (uint256 r = _nextRange[wallet]; r < list.length; ++r) {
uint256 left = list[r].count - list[r].delivered;
if (list[r].category == TapeoutMinerPool.Category.NAND) nandCount += left;
else latchCount += left;
}
}
/// @notice Index in `rangesOf(wallet)` of the first range with undelivered IDs (the next delivery
/// starts there); equal to the number of ranges once everything is delivered.
function nextRangeOf(address wallet) external view returns (uint256) {
return _nextRange[wallet];
}
function walletCount() external view returns (uint256) {
return _wallets.length;
}
function walletsPage(uint256 offset, uint256 limit) external view returns (address[] memory page) {
uint256 length = _wallets.length;
if (limit == 0 || limit > MAX_PAGE || offset > length) revert InvalidPage();
uint256 count = limit > length - offset ? length - offset : limit;
page = new address[](count);
for (uint256 i; i < count; ++i) {
page[i] = _wallets[offset + i];
}
}
/// @notice Inventory received, allocated and delivered per category ([NAND, LATCH]).
function totals()
external
view
returns (uint256[2] memory inventory, uint256[2] memory allocated, uint256[2] memory delivered)
{
return (_inventory, _allocated, _delivered);
}
// ---------------------------------------------------------------------------------- internals
function _categoryOf(address token) private view returns (TapeoutMinerPool.Category) {
if (token == address(nandReceipt)) return TapeoutMinerPool.Category.NAND;
if (token == address(latchReceipt)) return TapeoutMinerPool.Category.LATCH;
revert UnsupportedToken(token);
}
function _receipt(TapeoutMinerPool.Category category) private view returns (TapeoutEntitlementReceipt) {
return category == TapeoutMinerPool.Category.NAND ? nandReceipt : latchReceipt;
}
/// @dev Inventory must be fresh: a receipt whose BEM has ever been claimed is refused.
function _acceptOne(TapeoutMinerPool.Category category, uint256 id, uint256 value)
private
view
returns (bool)
{
if (value == 0) return false;
if (value != 1) revert InvalidAmount(id, value);
if (pool.claimed(category, id) != 0) revert ConsumedRewardHistory(category, id);
return true;
}
/// @dev Starts at the wallet's cursor, so a delivery pays only for the ranges and IDs it transfers,
/// however many ranges were completed before. Every range from the cursor on has undelivered
/// IDs (ranges hold at least one ID and only this function delivers). All bookkeeping, the
/// cursor included, is written before each transfer; a failing receiver reverts all of it.
function _deliver(address wallet, uint256 maxIds) private returns (uint256 total) {
if (!allocationFinalized) revert AllocationOpen();
Range[] storage list = _ranges[wallet];
uint256 length = list.length;
for (uint256 r = _nextRange[wallet]; r < length && total < maxIds; ++r) {
Range storage range = list[r];
uint256 left = range.count - range.delivered;
uint256 n = left < maxIds - total ? left : maxIds - total;
uint256 first = uint256(range.firstId) + range.delivered;
range.delivered += uint64(n);
_delivered[uint256(range.category)] += n;
total += n;
if (n == left) _nextRange[wallet] = r + 1;
uint256[] memory ids = new uint256[](n);
uint256[] memory values = new uint256[](n);
for (uint256 i; i < n; ++i) {
ids[i] = first + i;
values[i] = 1;
}
_receipt(range.category).safeBatchTransferFrom(address(this), wallet, ids, values, "");
emit Delivered(wallet, range.category, first, n);
}
if (total == 0) revert NothingToDeliver(wallet);
}
}