// SPDX-License-Identifier: MIT pragma solidity 0.8.26; interface IERC721 { function ownerOf(uint256 tokenId) external view returns (address); } interface IERC20 { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } interface IWETH { function withdraw(uint256 amount) external; } interface IPonsLocker { function collectFees(address token) external returns (uint256 amount0, uint256 amount1); } /** * A fee wallet that belongs to a set of collectible cards rather than to a person. * * Pons pays a launch's creator fees to whatever address sits in the locker's * feeRedirects slot. Point that at one of these and the fees stop being tied to * the launcher: every payout resolves ownerOf() at the moment it happens, so * the money follows whoever holds the card today. Sell the Charizard and the * stream goes with it. * * Two details of the Pons locker shape this contract: * * 1. It pays in ERC-20, never in native ETH — the pair leg arrives as WETH and * the coin leg as the token itself. So the payout path is ERC-20 first; * WETH is unwrapped on the way out purely so holders end up with real ETH. * 2. collectFees() only accepts the owner, the deployer or the fee recipient. * The recipient is this contract, so claim() below is what makes pulling * the fees possible for a card holder who launched nothing. * * There is no owner, no upgrade path and no withdraw function. The only way * value leaves is split across the cards. */ contract CardRouter { /// Where a share goes when its card can no longer be resolved. address public immutable creator; address[] private _cards; uint256[] private _ids; uint16[] private _bps; event Routed(address indexed asset, address indexed card, uint256 id, address to, uint256 amount); event Claimed(address indexed locker, address indexed token, uint256 amount0, uint256 amount1); event Received(address indexed from, uint256 amount); error BadRoutes(); error SharesMustSumTo10000(); error NothingToRelease(); error Stuck(); constructor(address[] memory cards_, uint256[] memory ids_, uint16[] memory bps_, address creator_) payable { uint256 n = cards_.length; if (n == 0 || n > 10 || ids_.length != n || bps_.length != n || creator_ == address(0)) revert BadRoutes(); uint256 total; for (uint256 i; i < n; ++i) { if (cards_[i] == address(0) || bps_[i] == 0) revert BadRoutes(); total += bps_[i]; } if (total != 10_000) revert SharesMustSumTo10000(); _cards = cards_; _ids = ids_; _bps = bps_; creator = creator_; } /// Needed so unwrapping WETH can pay in here. receive() external payable { emit Received(msg.sender, msg.value); } /* ------------------------------------------------------------------ */ /* reads */ /* ------------------------------------------------------------------ */ /// The marker Cardpad looks for. A fee wallet that answers this is a /// routing wallet, which is how launches are discovered without an index. function cardpadRoutes() external view returns (address[] memory cards, uint256[] memory ids, uint16[] memory bps) { return (_cards, _ids, _bps); } function routeCount() external view returns (uint256) { return _cards.length; } /// Who each share would be paid to right now. function holders() external view returns (address[] memory out) { uint256 n = _cards.length; out = new address[](n); for (uint256 i; i < n; ++i) out[i] = _holder(_cards[i], _ids[i]); } /* ------------------------------------------------------------------ */ /* pulling fees in */ /* ------------------------------------------------------------------ */ /// Permissionless: anyone may pull this coin's creator fees out of the Pons /// locker, because the locker sees the call coming from the fee recipient. function claim(address locker, address token) public returns (uint256 amount0, uint256 amount1) { (amount0, amount1) = IPonsLocker(locker).collectFees(token); emit Claimed(locker, token, amount0, amount1); } /// Turn the WETH leg into ETH so holders are paid in the chain's own currency. function unwrap(address weth) public returns (uint256 amount) { amount = IERC20(weth).balanceOf(address(this)); if (amount != 0) IWETH(weth).withdraw(amount); } /* ------------------------------------------------------------------ */ /* paying out */ /* ------------------------------------------------------------------ */ /// Split the whole native balance across the cards. function releaseNative() public returns (uint256 sent) { uint256 bal = address(this).balance; if (bal == 0) revert NothingToRelease(); sent = _split(address(0), bal); } /// Split the whole balance of an ERC-20 across the cards. function releaseToken(address asset) public returns (uint256 sent) { uint256 bal = IERC20(asset).balanceOf(address(this)); if (bal == 0) revert NothingToRelease(); sent = _split(asset, bal); } /** * The whole loop in one call, and the one the app uses: pull this coin's * fees out of the locker, unwrap the ETH leg, then pay out both legs. * * Nothing here reverts on an empty leg — a coin that has only traded on one * side still pays out the side that moved. */ function claimAndRelease(address locker, address token, address weth) external returns (uint256 nativeSent, uint256 tokenSent) { try IPonsLocker(locker).collectFees(token) returns (uint256 a0, uint256 a1) { emit Claimed(locker, token, a0, a1); } catch { // nothing new to collect; pay out whatever is already sitting here } if (weth != address(0)) unwrap(weth); uint256 nativeBal = address(this).balance; if (nativeBal != 0) nativeSent = _split(address(0), nativeBal); uint256 tokenBal = IERC20(token).balanceOf(address(this)); if (tokenBal != 0) tokenSent = _split(token, tokenBal); } /* ------------------------------------------------------------------ */ /* internals */ /* ------------------------------------------------------------------ */ function _split(address asset, uint256 bal) private returns (uint256 sent) { uint256 n = _cards.length; uint256 paid; for (uint256 i; i < n; ++i) { // the last card sweeps the remainder so rounding dust never sticks uint256 share = i + 1 == n ? bal - paid : (bal * _bps[i]) / 10_000; if (share == 0) continue; paid += share; address to = _holder(_cards[i], _ids[i]); if (!_pay(asset, to, share)) { to = creator; if (!_pay(asset, creator, share)) revert Stuck(); } sent += share; emit Routed(asset, _cards[i], _ids[i], to, share); } } /// address(0) means native. Returns false instead of reverting so one /// unreachable holder cannot freeze everybody else's share. function _pay(address asset, address to, uint256 amount) private returns (bool) { if (asset == address(0)) { (bool nativeOk,) = payable(to).call{ value: amount, gas: 40_000 }(""); return nativeOk; } (bool callOk, bytes memory ret) = asset.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount)); // tolerate the tokens that return nothing as well as the ones that return a bool return callOk && (ret.length == 0 || abi.decode(ret, (bool))); } function _holder(address card, uint256 id) private view returns (address) { try IERC721(card).ownerOf(id) returns (address o) { return o == address(0) ? creator : o; } catch { return creator; } } }