// SPDX-License-Identifier: MIT pragma solidity ^0.8.13; import "./LibMerkle.sol"; import "./LibStrings.sol"; import "./FlapVaultBase.sol"; interface IBandwidthTaxToken { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } /// @notice BNB settlement vault for verified bandwidth contributors. /// @dev The contributor ledger and Merkle roots are produced off-chain. This contract /// only accepts finalized roots and executes the resulting BNB claims. contract BandwidthSettlementVault is VaultBaseV2 { using LibStrings for uint256; struct VaultConfig { address operator; address guardian; address safeWallet; uint16 contributorTaxBps; uint16 advanceTaxBps; uint16 gasTaxBps; uint16 reserveTaxBps; uint16 opsTaxBps; uint16 externalContributorBps; uint16 externalAdvanceBps; uint16 externalGasBps; uint16 externalReserveBps; uint16 externalOpsBps; } error NotAuthorized(); error Paused(); error ZeroAmount(); error InvalidBps(); error InvalidProof(); error AlreadyClaimed(); error EpochNotSet(); error EpochOrder(); error InsufficientPoolBalance(); error Reentrancy(); error InvalidPool(); error EmptyAddress(); error InvalidAccessStaking(); error NotPaused(); error InvalidEpochAllocation(); error TokenBalanceReadFailed(); error TokenTransferFailed(); event TaxReceived(uint256 amount); event ExternalRevenueDeposited(uint256 amount); event EpochRootSet(uint256 indexed epoch, bytes32 root, uint256 total, string metadataURI); event ContributorClaimed(uint256 indexed epoch, address indexed account, uint256 amount); event PoolWithdrawn(uint8 indexed poolId, address indexed to, uint256 amount); event PausedSet(bool paused); event GuardianUpdated(address indexed guardian); event EmergencySwept(address indexed to, uint256 amount); event EmergencyTokenSwept(address indexed token, address indexed to, uint256 amount); address public immutable taxToken; address public immutable accessStaking; address private immutable safeWallet; address public operator; address public guardian; bool public paused; uint256 public latestEpoch; uint16 public contributorTaxBps; uint16 public advanceTaxBps; uint16 public gasTaxBps; uint16 public reserveTaxBps; uint16 public opsTaxBps; uint16 public externalContributorBps; uint16 public externalAdvanceBps; uint16 public externalGasBps; uint16 public externalReserveBps; uint16 public externalOpsBps; uint256 public contributorPoolBalance; uint256 public contributorReservedBalance; uint256 public advancePoolBalance; uint256 public gasPoolBalance; uint256 public reservePoolBalance; uint256 public opsPoolBalance; uint256 private locked; struct EpochInfo { bytes32 root; uint256 total; string metadataURI; bool exists; } mapping(uint256 => EpochInfo) public epochInfo; mapping(uint256 => mapping(address => bool)) public contributorClaimed; modifier onlyProjectAuthority() { if (msg.sender != operator && msg.sender != guardian && msg.sender != _getGuardian()) { revert NotAuthorized(); } _; } modifier whenNotPaused() { if (paused) revert Paused(); _; } modifier nonReentrant() { if (locked == 1) revert Reentrancy(); locked = 1; _; locked = 0; } constructor(address taxToken_, address accessStaking_, bytes memory configData) { if (taxToken_ == address(0)) revert EmptyAddress(); if (accessStaking_ == address(0)) revert InvalidAccessStaking(); taxToken = taxToken_; accessStaking = accessStaking_; VaultConfig memory cfg = abi.decode(configData, (VaultConfig)); if (cfg.operator == address(0) || cfg.safeWallet == address(0)) revert EmptyAddress(); operator = cfg.operator; guardian = cfg.guardian == address(0) ? cfg.operator : cfg.guardian; safeWallet = cfg.safeWallet; contributorTaxBps = cfg.contributorTaxBps; advanceTaxBps = cfg.advanceTaxBps; gasTaxBps = cfg.gasTaxBps; reserveTaxBps = cfg.reserveTaxBps; opsTaxBps = cfg.opsTaxBps; externalContributorBps = cfg.externalContributorBps; externalAdvanceBps = cfg.externalAdvanceBps; externalGasBps = cfg.externalGasBps; externalReserveBps = cfg.externalReserveBps; externalOpsBps = cfg.externalOpsBps; _validateSplits(); } receive() external payable { if (msg.value == 0) revert ZeroAmount(); _distributeTaxRevenue(msg.value); emit TaxReceived(msg.value); } function description() public view override returns (string memory) { return string( abi.encodePacked( unicode"RelayVault 带宽结算金库|接收 BNB 税收与服务商回款,按最终确认的小时周期向带宽贡献者结算。", unicode" 税收分配:贡献者 ", uint256(contributorTaxBps).toString(), unicode" bps · 时间差储备 ", uint256(advanceTaxBps).toString(), unicode" bps · Gas ", uint256(gasTaxBps).toString(), unicode" bps · 储备 ", uint256(reserveTaxBps).toString(), unicode" bps · 运营 ", uint256(opsTaxBps).toString(), unicode" bps。当前状态:", paused ? unicode"已暂停" : unicode"运行中", unicode"|最新周期:", latestEpoch.toString(), unicode"|访问质押:", accessStaking == address(0) ? unicode"未配置" : unicode"已绑定" ) ); } /// @dev Emergency and unrestricted pool-transfer methods are intentionally omitted /// from this public schema. They remain callable by project authority on-chain. function vaultUISchema() public pure override returns (VaultUISchema memory schema) { schema.vaultType = unicode"RelayVault 带宽结算金库"; schema.description = unicode"真实 BNB 链上带宽贡献结算。余额来自合约读取,领取需要已确认周期的 Merkle 证明。"; schema.methods = new VaultMethodSchema[](7); schema.methods[0].name = "poolBalances"; schema.methods[0].description = unicode"读取贡献者、时间差储备、Gas、项目储备和运营池余额。"; schema.methods[0].outputs = new FieldDescriptor[](5); schema.methods[0].outputs[0] = FieldDescriptor("contributorPool", "uint256", unicode"贡献者奖励池", 18); schema.methods[0].outputs[1] = FieldDescriptor("advancePool", "uint256", unicode"时间差储备池", 18); schema.methods[0].outputs[2] = FieldDescriptor("gasPool", "uint256", unicode"Gas 费用池", 18); schema.methods[0].outputs[3] = FieldDescriptor("reservePool", "uint256", unicode"项目储备池", 18); schema.methods[0].outputs[4] = FieldDescriptor("opsPool", "uint256", unicode"运营池", 18); schema.methods[0].approvals = new ApproveAction[](0); schema.methods[1].name = "availableContributorPool"; schema.methods[1].description = unicode"读取尚未被已确认周期预留的贡献者余额。"; schema.methods[1].outputs = new FieldDescriptor[](1); schema.methods[1].outputs[0] = FieldDescriptor("amount", "uint256", unicode"可用于后续结算的余额", 18); schema.methods[1].approvals = new ApproveAction[](0); schema.methods[2].name = "latestEpoch"; schema.methods[2].description = unicode"读取最新的已确认结算周期。"; schema.methods[2].outputs = new FieldDescriptor[](1); schema.methods[2].outputs[0] = FieldDescriptor("epoch", "uint256", unicode"最新周期", 0); schema.methods[2].approvals = new ApproveAction[](0); schema.methods[3].name = "claimContributor"; schema.methods[3].description = unicode"使用有效 Merkle 证明领取已确认周期的带宽贡献奖励。"; schema.methods[3].inputs = new FieldDescriptor[](3); schema.methods[3].inputs[0] = FieldDescriptor("epoch", "uint256", unicode"结算周期", 0); schema.methods[3].inputs[1] = FieldDescriptor("amount", "uint256", unicode"领取 BNB 数量", 18); schema.methods[3].inputs[2] = FieldDescriptor("proof", "bytes32[]", unicode"Merkle 证明", 0); schema.methods[3].approvals = new ApproveAction[](0); schema.methods[3].isWriteMethod = true; schema.methods[4].name = "paused"; schema.methods[4].description = unicode"读取领取暂停状态。"; schema.methods[4].outputs = new FieldDescriptor[](1); schema.methods[4].outputs[0] = FieldDescriptor("paused", "bool", unicode"是否暂停", 0); schema.methods[4].approvals = new ApproveAction[](0); schema.methods[5].name = "epochSummary"; schema.methods[5].description = unicode"读取指定周期的 Merkle 根、总额和元数据。"; schema.methods[5].inputs = new FieldDescriptor[](1); schema.methods[5].inputs[0] = FieldDescriptor("epoch", "uint256", unicode"结算周期", 0); schema.methods[5].outputs = new FieldDescriptor[](4); schema.methods[5].outputs[0] = FieldDescriptor("root", "bytes32", unicode"贡献者 Merkle 根", 0); schema.methods[5].outputs[1] = FieldDescriptor("total", "uint256", unicode"周期总额", 18); schema.methods[5].outputs[2] = FieldDescriptor("metadataURI", "string", unicode"周期数据说明", 0); schema.methods[5].outputs[3] = FieldDescriptor("exists", "bool", unicode"是否已确认", 0); schema.methods[5].approvals = new ApproveAction[](0); schema.methods[6].name = "accessStaking"; schema.methods[6].description = unicode"读取贡献者必须满足的共享带宽代币质押合约地址。"; schema.methods[6].outputs = new FieldDescriptor[](1); schema.methods[6].outputs[0] = FieldDescriptor("staking", "address", unicode"访问质押合约", 0); schema.methods[6].approvals = new ApproveAction[](0); } function poolBalances() external view returns (uint256 contributorPool, uint256 advancePool, uint256 gasPool, uint256 reservePool, uint256 opsPool) { return (contributorPoolBalance, advancePoolBalance, gasPoolBalance, reservePoolBalance, opsPoolBalance); } function availableContributorPool() public view returns (uint256) { return contributorPoolBalance - contributorReservedBalance; } function setEpochRoot(uint256 epoch, bytes32 root, uint256 total, string calldata metadataURI) external onlyProjectAuthority { if (epoch == 0 || root == bytes32(0) || total == 0) revert InvalidEpochAllocation(); if (epoch <= latestEpoch || epochInfo[epoch].exists) revert EpochOrder(); if (total > availableContributorPool()) revert InsufficientPoolBalance(); epochInfo[epoch] = EpochInfo({root: root, total: total, metadataURI: metadataURI, exists: true}); latestEpoch = epoch; contributorReservedBalance += total; emit EpochRootSet(epoch, root, total, metadataURI); } function claimContributor(uint256 epoch, uint256 amount, bytes32[] calldata proof) external nonReentrant whenNotPaused { EpochInfo storage info = epochInfo[epoch]; if (!info.exists) revert EpochNotSet(); if (contributorClaimed[epoch][msg.sender]) revert AlreadyClaimed(); if (amount == 0 || amount > contributorPoolBalance) revert InsufficientPoolBalance(); bytes32 leaf = keccak256(abi.encodePacked("CONTRIBUTOR", epoch, msg.sender, amount)); if (!LibMerkle.verifyCalldata(proof, info.root, leaf)) revert InvalidProof(); contributorClaimed[epoch][msg.sender] = true; contributorPoolBalance -= amount; contributorReservedBalance -= amount; _sendValue(payable(msg.sender), amount); emit ContributorClaimed(epoch, msg.sender, amount); } /// @notice Compatibility alias for Flap integrations that use the node claim name. function claimNode(uint256 epoch, uint256 amount, bytes32[] calldata proof) external nonReentrant whenNotPaused { _claimContributor(epoch, amount, proof); } function depositExternalRevenue() external payable onlyProjectAuthority { if (msg.value == 0) revert ZeroAmount(); _distributeExternalRevenue(msg.value); emit ExternalRevenueDeposited(msg.value); } /// @notice Transfers an authority-selected pool amount. Kept out of the public UI. /// Contributor transfers are limited to amounts not reserved by finalized epochs. function withdrawPool(uint8 poolId, address payable to, uint256 amount) external onlyProjectAuthority nonReentrant { if (to == address(0) || amount == 0) revert EmptyAddress(); if (poolId == 0) { if (amount > availableContributorPool()) revert InsufficientPoolBalance(); contributorPoolBalance -= amount; } else if (poolId == 1) { if (amount > advancePoolBalance) revert InsufficientPoolBalance(); advancePoolBalance -= amount; } else if (poolId == 2) { if (amount > gasPoolBalance) revert InsufficientPoolBalance(); gasPoolBalance -= amount; } else if (poolId == 3) { if (amount > reservePoolBalance) revert InsufficientPoolBalance(); reservePoolBalance -= amount; } else if (poolId == 4) { if (amount > opsPoolBalance) revert InsufficientPoolBalance(); opsPoolBalance -= amount; } else { revert InvalidPool(); } _sendValue(to, amount); emit PoolWithdrawn(poolId, to, amount); } function pause() external onlyProjectAuthority { paused = true; emit PausedSet(true); } function unpause() external onlyProjectAuthority { paused = false; emit PausedSet(false); } function setGuardian(address newGuardian) external onlyProjectAuthority { if (newGuardian == address(0)) revert EmptyAddress(); guardian = newGuardian; emit GuardianUpdated(newGuardian); } /// @notice Sends every native token balance to the immutable emergency wallet after pause. function emergencySweep() external onlyProjectAuthority nonReentrant { if (!paused) revert NotPaused(); uint256 amount = address(this).balance; contributorPoolBalance = 0; contributorReservedBalance = 0; advancePoolBalance = 0; gasPoolBalance = 0; reservePoolBalance = 0; opsPoolBalance = 0; _sendValue(payable(safeWallet), amount); emit EmergencySwept(safeWallet, amount); } function emergencySweepToken(address token) external onlyProjectAuthority nonReentrant { if (!paused || token == address(0)) revert NotPaused(); uint256 amount = _tokenBalance(token, address(this)); if (amount == 0) revert ZeroAmount(); _safeTransfer(token, safeWallet, amount); emit EmergencyTokenSwept(token, safeWallet, amount); } function epochSummary(uint256 epoch) external view returns (bytes32 root, uint256 total, string memory metadataURI, bool exists) { EpochInfo storage info = epochInfo[epoch]; return (info.root, info.total, info.metadataURI, info.exists); } function _claimContributor(uint256 epoch, uint256 amount, bytes32[] calldata proof) internal { EpochInfo storage info = epochInfo[epoch]; if (!info.exists) revert EpochNotSet(); if (contributorClaimed[epoch][msg.sender]) revert AlreadyClaimed(); if (amount == 0 || amount > contributorPoolBalance) revert InsufficientPoolBalance(); bytes32 leaf = keccak256(abi.encodePacked("CONTRIBUTOR", epoch, msg.sender, amount)); if (!LibMerkle.verifyCalldata(proof, info.root, leaf)) revert InvalidProof(); contributorClaimed[epoch][msg.sender] = true; contributorPoolBalance -= amount; contributorReservedBalance -= amount; _sendValue(payable(msg.sender), amount); emit ContributorClaimed(epoch, msg.sender, amount); } function _distributeTaxRevenue(uint256 amount) internal { uint256 contributorShare = (amount * contributorTaxBps) / 10_000; uint256 advanceShare = (amount * advanceTaxBps) / 10_000; uint256 gasShare = (amount * gasTaxBps) / 10_000; uint256 reserveShare = (amount * reserveTaxBps) / 10_000; uint256 opsShare = amount - contributorShare - advanceShare - gasShare - reserveShare; contributorPoolBalance += contributorShare; advancePoolBalance += advanceShare; gasPoolBalance += gasShare; reservePoolBalance += reserveShare; opsPoolBalance += opsShare; } function _distributeExternalRevenue(uint256 amount) internal { uint256 contributorShare = (amount * externalContributorBps) / 10_000; uint256 advanceShare = (amount * externalAdvanceBps) / 10_000; uint256 gasShare = (amount * externalGasBps) / 10_000; uint256 reserveShare = (amount * externalReserveBps) / 10_000; uint256 opsShare = amount - contributorShare - advanceShare - gasShare - reserveShare; contributorPoolBalance += contributorShare; advancePoolBalance += advanceShare; gasPoolBalance += gasShare; reservePoolBalance += reserveShare; opsPoolBalance += opsShare; } function _sendValue(address payable to, uint256 amount) internal { (bool ok,) = to.call{value: amount}(""); require(ok, "BNB_TRANSFER_FAILED"); } function _tokenBalance(address token, address account) internal view returns (uint256 amount) { (bool ok, bytes memory data) = token.staticcall(abi.encodeWithSelector(IBandwidthTaxToken.balanceOf.selector, account)); if (!ok || data.length < 32) revert TokenBalanceReadFailed(); amount = abi.decode(data, (uint256)); } function _safeTransfer(address token, address to, uint256 amount) internal { (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IBandwidthTaxToken.transfer.selector, to, amount)); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed(); } function _validateSplits() internal view { if ( uint256(contributorTaxBps) + uint256(advanceTaxBps) + uint256(gasTaxBps) + uint256(reserveTaxBps) + uint256(opsTaxBps) != 10_000 ) revert InvalidBps(); if ( uint256(externalContributorBps) + uint256(externalAdvanceBps) + uint256(externalGasBps) + uint256(externalReserveBps) + uint256(externalOpsBps) != 10_000 ) revert InvalidBps(); } }