diff --git a/.gitmodules b/.gitmodules index 888d42d..c7b4bb4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/filecoin-pay"] + path = lib/filecoin-pay + url = https://github.com/filozone/filecoin-pay diff --git a/AGENTS.md b/AGENTS.md index 519a1f0..f2a8cc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ # Bash commands - `forge fmt`: Format the project - `forge test`: Test the project +- `forge install`: Install dependencies # Workflow - Make sure to run `forge fmt` when you're done making a series of test changes - Prefer running single tests, and not the whole test suite, for performance +- Update [SPEC.md](SPEC.md) and [README.md](README.md) when making changes to the contract API diff --git a/README.md b/README.md index 8817d6a..18cb430 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,21 @@ -## Foundry +# FilBeamOperator Contract + +FilBeamOperator is a smart contract used for aggregating CDN and cache-miss usage data and managing payment settlements for CDN payment rails operated by [Filecoin Warm Storage Service](https://github.com/FilOzone/filecoin-services). -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +## Features -Foundry consists of: +- **Usage Reporting**: Batch methods for reporting CDN and cache-miss usage +- **Rail Settlements**: Independent settlement for CDN and cache-miss payment rails +- **Access Control**: Separate roles for contract management and usage reporting -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +## Foundry -## Documentation +Documentation: https://book.getfoundry.sh/ -https://book.getfoundry.sh/ +## Prerequisites +- [Foundry](https://getfoundry.sh/) - Ethereum development toolchain -## Usage +### Usage ### Build @@ -33,24 +35,93 @@ $ forge test $ forge fmt ``` -### Gas Snapshots +### Deploy FilBeamOperator Contract -```shell -$ forge snapshot +The FilBeamOperator contract requires the following constructor parameters: + +```solidity +constructor( + address fwssAddress, // FWSS contract address + address _paymentsAddress, // Payments contract address for rail management + uint256 _cdnRatePerByte, // Rate per byte for CDN usage + uint256 _cacheMissRatePerByte, // Rate per byte for cache-miss usage + address _filBeamOperatorController // Address authorized to report usage +) ``` -### Anvil +#### Deployment Example + +Deploy the contract using Forge script: + +```bash +PRIVATE_KEY= \ +FILBEAM_CONTROLLER= \ +PAYMENTS_ADDRESS= \ +FWSS_ADDRESS= \ +PAYMENTS_ADDRESS= \ +CDN_PRICE_USD_PER_TIB= \ +CACHE_MISS_PRICE_USD_PER_TIB= \ +PRICE_DECIMALS= \ +forge script script/DeployFilBeamOperator.s.sol \ +--rpc-url \ +--broadcast +``` -```shell -$ anvil +**Note**: The deployer address automatically becomes the contract owner. + +## Contract API + +### Usage Reporting + +```solidity +function recordUsageRollups( + uint256 toEpoch, + uint256[] calldata dataSetIds, + uint256[] calldata cdnBytesUsed, + uint256[] calldata cacheMissBytesUsed +) external onlyFilBeamOperatorController ``` -### Deploy +### Settlement Operations -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +```solidity +function settleCDNPaymentRails(uint256[] calldata dataSetIds) external +function settleCacheMissPaymentRails(uint256[] calldata dataSetIds) external ``` +### Data Set Management + +**Payment Rail Termination** +```solidity +function terminateCDNPaymentRails(uint256 dataSetId) external onlyFilBeamOperatorController +``` + +### Contract Management + +**Ownership & Controller** +```solidity +function transferOwnership(address newOwner) external onlyOwner +function setFilBeamOperatorController(address _filBeamOperatorController) external onlyOwner +``` + +## Key Concepts + +### Batch Operations +- **Gas Efficient**: Reduce transaction costs for bulk operations +- **Atomic**: All operations in a batch succeed or all fail +- **Independent Rails**: CDN and cache-miss settlements operate independently + +### Pricing Model +- **Usage-Based**: Calculated as `usage_bytes * rate_per_byte` at report time +- **Immutable Rates**: Rates are set at deployment and cannot be changed, ensuring predictable pricing +- **Transparent Pricing**: All users can view the fixed rates on-chain +- **Partial Settlements**: Supports partial settlements when accumulated amount exceeds payment rail's `lockupFixed` + +### Rail Settlement +- **Independent Tracking**: CDN and cache-miss settlements tracked separately +- **Epoch-Based**: Settlement periods defined by epoch ranges +- **Accumulative**: Usage accumulates between settlements + ### Cast ```shell diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..41e74ae --- /dev/null +++ b/SPEC.md @@ -0,0 +1,203 @@ +## Specification + +### FilBeamOperator (Operator) Contract + +#### Overview + +The Filecoin Beam (FilBeamOperator) contract is responsible for managing CDN (cache-hit) and cache-miss data set egress usage data reported by the off-chain rollup worker and settlement of payment rails. Payment rails are managed by the Filecoin Warm Storage Service (FWSS) contract. The FilBeamOperator contract interacts with the FWSS contract to facilitate fund transfers based on reported usage data with rate-based billing. + +#### Initialization +**Method**: `constructor(address fwssAddress, uint256 _cdnRatePerByte, uint256 _cacheMissRatePerByte, address _filBeamOperatorController)` + +**Parameters**: +- `address fwssAddress`: Address of the FWSS contract +- `uint256 _cdnRatePerByte`: Rate per byte for CDN usage billing (must be > 0) +- `uint256 _cacheMissRatePerByte`: Rate per byte for cache-miss usage billing (must be > 0) +- `address _filBeamOperatorController`: Address authorized to report usage and terminate payment rails + +**Owner**: +- The deployer (msg.sender) automatically becomes the contract owner + +**Validations**: +- FWSS address cannot be zero address +- Both rates must be greater than zero +- FilBeamOperator controller cannot be zero address + +#### Data Structure +**DataSetUsage Struct**: +- `uint256 cdnAmount`: Accumulated CDN settlement amount between settlements (calculated at report time) +- `uint256 cacheMissAmount`: Accumulated cache-miss settlement amount between settlements (calculated at report time) +- `uint256 maxReportedEpoch`: Highest epoch number reported for this dataset (0 indicates uninitialized dataset) +- `uint256 lastCDNSettlementEpoch`: Last epoch settled for CDN payment rail +- `uint256 lastCacheMissSettlementEpoch`: Last epoch settled for cache-miss payment rail + +#### Usage Reporting + +**Method**: `recordUsageRollups(uint256 toEpoch, uint256[] dataSetIds, uint256[] cdnBytesUsed, uint256[] cacheMissBytesUsed)` + +- **Access**: FilBeamOperator controller only +- **Purpose**: Accepts multiple usage reports in a single transaction for improved gas efficiency +- **toEpoch Parameter**: Single epoch number up to which usage is reported for all datasets in the batch +- **Epoch Requirements**: + - Epoch must be > 0 + - Epoch must be greater than previously reported epochs for each dataset + - Each epoch can only be reported once per dataset +- **Usage Requirements**: + - Usage is converted to settlement amounts using current rates at report time + - Amounts accumulate in the dataset between settlements +- **Parameter Requirements**: + - All arrays must have equal length + - All datasets in the batch report usage up to the same toEpoch +- **Batch Processing**: + - Processes all reports atomically (all succeed or all fail) + - Maintains epoch ordering and validation rules per dataset + - Prevents duplicate epoch reporting within the batch +- **State Updates**: + - Initialize dataset on first report (sets maxReportedEpoch to non-zero value) + - Calculate amounts: `cdnAmount = cdnBytesUsed * cdnRatePerByte`, `cacheMissAmount = cacheMissBytesUsed * cacheMissRatePerByte` + - Accumulate calculated amounts + - Update max reported epoch to toEpoch +- **Events**: Emits individual `UsageReported` event for each processed report (contains bytes, not amounts) + +#### Payment Rail Settlement + +**Method**: `settleCDNPaymentRails(uint256[] dataSetIds)` + +- **Access**: Publicly callable (anyone can trigger settlement) +- **Purpose**: Settles CDN payment rails for multiple datasets in a single transaction +- **Calculation Period**: From last CDN settlement epoch + 1 to max reported epoch +- **Settlement Logic**: + - Retrieves rail ID from FWSS DataSetInfo + - Fetches rail details from Payments contract to get `lockupFixed` + - Calculates settleable amount: `min(accumulated_amount, rail.lockupFixed)` + - Only calls FWSS contract if settleable amount > 0 + - Reduces accumulated CDN amount by settled amount (may leave remainder) +- **State Updates**: + - Update last CDN settlement epoch to max reported epoch + - Reduce accumulated amount by settled amount (not reset to zero if partial) +- **Requirements**: None - gracefully skips datasets that cannot be settled +- **Batch Processing**: + - Processes each dataset independently (non-reverting) + - Skips uninitialized datasets or those without new usage + - Skips datasets without valid rail configuration + - Continues processing even if some datasets cannot be settled +- **Partial Settlement**: Supports partial settlements when `accumulated_amount > lockupFixed` +- **Events**: Emits `CDNSettlement` event with actual settled amount (may be less than accumulated) +- **Independent Operation**: Can be called independently of cache-miss settlement + +**Method**: `settleCacheMissPaymentRails(uint256[] dataSetIds)` + +- **Access**: Publicly callable (typically called by Storage Providers) +- **Purpose**: Settles cache-miss payment rails for multiple datasets in a single transaction +- **Calculation Period**: From last cache-miss settlement epoch + 1 to max reported epoch +- **Settlement Logic**: + - Retrieves rail ID from FWSS DataSetInfo + - Fetches rail details from Payments contract to get `lockupFixed` + - Calculates settleable amount: `min(accumulated_amount, rail.lockupFixed)` + - Only calls FWSS contract if settleable amount > 0 + - Reduces accumulated cache-miss amount by settled amount (may leave remainder) +- **State Updates**: + - Update last cache-miss settlement epoch to max reported epoch + - Reduce accumulated amount by settled amount (not reset to zero if partial) +- **Requirements**: None - gracefully skips datasets that cannot be settled +- **Batch Processing**: + - Processes each dataset independently (non-reverting) + - Skips uninitialized datasets or those without new usage + - Skips datasets without valid rail configuration + - Continues processing even if some datasets cannot be settled +- **Partial Settlement**: Supports partial settlements when `accumulated_amount > lockupFixed` +- **Events**: Emits `CacheMissSettlement` event with actual settled amount (may be less than accumulated) +- **Independent Operation**: Can be called independently of CDN settlement + +#### Payment Rail Termination +**Method**: `terminateCDNPaymentRails(uint256 dataSetId)` + +- **Access**: FilBeamOperator controller only +- **Requirements**: Dataset must be initialized +- **Process**: Forward termination call to FWSS contract +- **Events**: Emits `PaymentRailsTerminated` event + +#### Data Access +**Method**: `getDataSetUsage(uint256 dataSetId)` + +**Returns**: +- `uint256 cdnAmount`: Current accumulated CDN settlement amount +- `uint256 cacheMissAmount`: Current accumulated cache-miss settlement amount +- `uint256 maxReportedEpoch`: Highest reported epoch (0 indicates uninitialized dataset) +- `uint256 lastCDNSettlementEpoch`: Last CDN settlement epoch +- `uint256 lastCacheMissSettlementEpoch`: Last cache-miss settlement epoch + +#### Ownership Management +**Method**: `transferOwnership(address newOwner)` + +- **Access**: Contract owner only +- **Requirements**: New owner cannot be zero address +- **Purpose**: Transfer contract ownership + +#### FilBeamOperator Controller Management +**Method**: `setFilBeamOperatorController(address _filBeamOperatorController)` + +- **Access**: Contract owner only +- **Requirements**: FilBeamOperator controller cannot be zero address +- **Purpose**: Update the authorized address for usage reporting and payment rail termination +- **Events**: Emits `FilBeamOperatorControllerUpdated` event + +#### Events +- `UsageReported(uint256 indexed dataSetId, uint256 indexed fromEpoch, uint256 indexed toEpoch, uint256 cdnBytesUsed, uint256 cacheMissBytesUsed)` +- `CDNSettlement(uint256 indexed dataSetId, uint256 fromEpoch, uint256 toEpoch, uint256 cdnAmount)` +- `CacheMissSettlement(uint256 indexed dataSetId, uint256 fromEpoch, uint256 toEpoch, uint256 cacheMissAmount)` +- `PaymentRailsTerminated(uint256 indexed dataSetId)` +- `FilBeamOperatorControllerUpdated(address indexed oldController, address indexed newController)` + +#### Access Control +- **Owner**: Address authorized to manage contract ownership and set FilBeamOperator controller +- **FilBeamOperator Controller**: Address authorized to report usage and terminate payment rails + +#### Error Conditions +- `OwnableUnauthorizedAccount(address)`: Caller is not the contract owner +- `Unauthorized()`: Caller is not the FilBeamOperator controller +- `InvalidEpoch()`: Invalid epoch number or ordering (used in usage reporting) +- `InvalidUsageAmount()`: Invalid array lengths in batch operations +- `InvalidRate()`: Invalid rate configuration (zero rates at deployment) +- `InvalidAddress()`: Invalid address (zero address) provided + +### Filecoin Warm Storage Service (FWSS) Contract Interface + +**Method**: `settleFilBeamPaymentRails(uint256 dataSetId, uint256 cdnAmount, uint256 cacheMissAmount)` +- **Purpose**: Settle CDN or cache-miss payment rails based on calculated amounts +- **Access**: Callable only by FilBeamOperator contract +- **Parameters**: Either cdnAmount or cacheMissAmount will be zero depending on settlement type + +**Method**: `terminateCDNPaymentRails(uint256 dataSetId)` +- **Purpose**: Terminate CDN payment rails for a specific dataset +- **Access**: Callable only by FilBeamOperator contract + +### Key Implementation Features + +#### Rate-Based Settlement +- Immutable rates per byte for both CDN and cache-miss usage set at contract deployment +- Settlement amounts calculated at report time as: `usage * rate` +- Rates cannot be changed after deployment, ensuring predictable pricing + +#### Independent Settlement Rails +- CDN and cache-miss settlements operate independently +- Each rail tracks its own settlement epoch +- Allows flexible settlement patterns for different stakeholders + +#### Amount Accumulation +- Settlement amounts (calculated at report time) accumulate between settlements +- Only unsettled amounts are stored in contract state +- Settlement reduces accumulated amounts by settled amount (supports partial settlements) + +#### Epoch Management +- Strict epoch ordering enforcement +- Prevents duplicate epoch reporting +- Supports batched reporting of multiple epochs via `recordUsageRollups` method for gas efficiency +- Independent epoch tracking per dataset + +#### Payments Contract Integration +- Integrates with external Payments contract to enforce lockup limits +- Retrieves rail information including `lockupFixed` to determine maximum settleable amount +- Supports partial settlements when accumulated amount exceeds available lockup +- Gracefully handles missing or invalid rails by skipping settlement +- Multiple settlement calls may be required to fully settle large accumulated amounts diff --git a/foundry.lock b/foundry.lock index 5643642..f1d2b20 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,4 +1,22 @@ { + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.4.0", + "rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0" + } + }, + "lib/filecoin-pay": { + "tag": { + "name": "v0.6.0", + "rev": "3f022bd5f3e305d32e1221551e933f662df8da18" + } + }, + "lib/openzeppelin-contracts-upgradeable": { + "tag": { + "name": "v5.4.0", + "rev": "e725abddf1e01cf05ace496e950fc8e243cc7cab" + } + }, "lib/forge-std": { "tag": { "name": "v1.10.0", diff --git a/foundry.toml b/foundry.toml index 25b918f..258021b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,17 @@ [profile.default] -src = "src" -out = "out" -libs = ["lib"] +src = 'src' +test = 'test' +script = 'script' +out = 'out' +libs = ['lib'] +cache_path = 'cache' +solc = "0.8.30" +via_ir = true +optimizer = true +optimizer_runs = 200 -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + +# For dependencies +remappings = [ + '@filecoin-pay/=lib/filecoin-pay/src/', +] diff --git a/lib/filecoin-pay b/lib/filecoin-pay new file mode 160000 index 0000000..3f022bd --- /dev/null +++ b/lib/filecoin-pay @@ -0,0 +1 @@ +Subproject commit 3f022bd5f3e305d32e1221551e933f662df8da18 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..c64a1ed --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit c64a1edb67b6e3f4a15cca8909c9482ad33a02b0 diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index f01d69c..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/script/DeployFilBeamOperator.s.sol b/script/DeployFilBeamOperator.s.sol new file mode 100644 index 0000000..ef64ac0 --- /dev/null +++ b/script/DeployFilBeamOperator.s.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import "../src/FilBeamOperator.sol"; +import "../src/interfaces/IFWSS.sol"; + +interface IERC20 { + function decimals() external view returns (uint8); +} + +/** + * @title DeployFilBeamOperator + * @dev Deploys FilBeamOperator contract with USDFC token integration + * + * The USDFC token address is automatically read from the FWSS contract. + * + * Required Environment Variables: + * - PRIVATE_KEY: Deployer's private key (deployer becomes initial owner) + * - FWSS_ADDRESS: Address of the FWSS contract + * - PAYMENTS_ADDRESS: Address of Filecoin Pay contract + * - CDN_PRICE_USD_PER_TIB: CDN price in USD per TiB (scaled by PRICE_DECIMALS, e.g., 1250 for $12.50/TiB) + * - CACHE_MISS_PRICE_USD_PER_TIB: Cache miss price in USD per TiB (scaled by PRICE_DECIMALS, e.g., 1575 for $15.75/TiB) + * - PRICE_DECIMALS: Number of decimal places for price inputs (e.g., 2 for cents, 0 for whole dollars) + * + * Optional Environment Variables: + * - FILBEAM_CONTROLLER: Address authorized to report usage (defaults to deployer) + * + * Example usage: + * PRIVATE_KEY=0x123... PAYMENTS_ADDRESS=0xabc... FWSS_ADDRESS=0xabc... CDN_PRICE_USD_PER_TIB=1250 CACHE_MISS_PRICE_USD_PER_TIB=1575 PRICE_DECIMALS=2 forge script script/DeployFilBeamOperator.s.sol --broadcast + */ +contract DeployFilBeamOperator is Script { + // Constants for conversion + uint256 constant BYTES_PER_TIB = 1024 * 1024 * 1024 * 1024; // 1 TiB in bytes + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address fwssAddress = vm.envAddress("FWSS_ADDRESS"); + + // Query FWSS contract for USDFC token address + IFWSS fwssContract = IFWSS(fwssAddress); + address usdfcAddress = fwssContract.usdfcTokenAddress(); + + // Get filBeamOperatorController address (defaults to deployer if not set) + address filBeamOperatorController = vm.envOr("FILBEAM_CONTROLLER", deployer); + + // Get USD prices per TiB (scaled by PRICE_DECIMALS, e.g., 1250 for $12.50/TiB with 2 decimals) + uint256 cdnPriceUsdPerTibScaled = vm.envUint("CDN_PRICE_USD_PER_TIB"); + uint256 cacheMissPriceUsdPerTibScaled = vm.envUint("CACHE_MISS_PRICE_USD_PER_TIB"); + uint8 priceDecimals = uint8(vm.envUint("PRICE_DECIMALS")); + + // Query USDFC contract for decimals + IERC20 usdfcContract = IERC20(usdfcAddress); + uint8 usdfcDecimals = usdfcContract.decimals(); + + // Calculate USDFC per byte rates + uint256 cdnRatePerByte = calculateUsdfcPerByte(cdnPriceUsdPerTibScaled, priceDecimals, usdfcDecimals); + uint256 cacheMissRatePerByte = + calculateUsdfcPerByte(cacheMissPriceUsdPerTibScaled, priceDecimals, usdfcDecimals); + + vm.startBroadcast(deployerPrivateKey); + + // Get payments address from environment + address paymentsAddress = vm.envAddress("PAYMENTS_ADDRESS"); + + // Deploy the FilBeamOperator contract (deployer becomes owner) + FilBeamOperator filBeam = new FilBeamOperator( + fwssAddress, paymentsAddress, cdnRatePerByte, cacheMissRatePerByte, filBeamOperatorController + ); + + vm.stopBroadcast(); + + // Log deployment information + console2.log("=== FilBeamOperator Deployment Complete ==="); + console2.log("FilBeamOperator deployed at:", address(filBeam)); + console2.log(""); + console2.log("=== Configuration ==="); + console2.log("FWSS Address:", fwssAddress); + console2.log("Payments Address:", paymentsAddress); + console2.log("USDFC Address:", usdfcAddress); + console2.log("USDFC Decimals:", usdfcDecimals); + console2.log("Price Decimals:", priceDecimals); + console2.log("Owner:", deployer); + console2.log("FilBeamOperator Controller:", filBeamOperatorController); + console2.log(""); + console2.log("=== Pricing ==="); + console2.log("CDN Price (scaled input):", cdnPriceUsdPerTibScaled); + console2.log("CDN Rate (USDFC per byte):", cdnRatePerByte); + console2.log("Cache Miss Price (scaled input):", cacheMissPriceUsdPerTibScaled); + console2.log("Cache Miss Rate (USDFC per byte):", cacheMissRatePerByte); + + if (priceDecimals > 0) { + console2.log(""); + console2.log("=== Actual USD Prices ==="); + console2.log("CDN: scaled %d with %d decimals", cdnPriceUsdPerTibScaled, priceDecimals); + console2.log("Cache Miss: scaled %d with %d decimals", cacheMissPriceUsdPerTibScaled, priceDecimals); + } + } + + /** + * @dev Converts USD per TiB to USDFC per byte with decimal price support + * @param usdPerTibScaled Price in USD per TiB scaled by priceDecimals (e.g., 1250 for $12.50/TiB with 2 decimals) + * @param priceDecimals Number of decimal places in the price input (e.g., 2 for cents) + * @param tokenDecimals Number of decimal places in the USDFC token + * @return USDFC per byte (scaled by USDFC token decimals) + */ + function calculateUsdfcPerByte(uint256 usdPerTibScaled, uint8 priceDecimals, uint8 tokenDecimals) + internal + pure + returns (uint256) + { + // Convert scaled USD to USDFC (assuming 1:1 parity) + // Account for price decimals: 1250 with 2 decimals = $12.50 + // Scale by USDFC token decimals (e.g., 6 decimals = 10^6) + uint256 usdfcPerTib = (usdPerTibScaled * (10 ** tokenDecimals)) / (10 ** priceDecimals); + + // Convert per TiB to per byte + uint256 usdfcPerByte = usdfcPerTib / BYTES_PER_TIB; + + return usdfcPerByte; + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Errors.sol b/src/Errors.sol new file mode 100644 index 0000000..91fc702 --- /dev/null +++ b/src/Errors.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +error OnlyOwner(); +error Unauthorized(); +error InvalidEpoch(); +error NoUsageToSettle(); +error InvalidUsageAmount(); +error DataSetNotInitialized(); +error InvalidRate(); +error InvalidAddress(); diff --git a/src/FilBeamOperator.sol b/src/FilBeamOperator.sol new file mode 100644 index 0000000..a26a054 --- /dev/null +++ b/src/FilBeamOperator.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "./interfaces/IFWSS.sol"; +import "./Errors.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import {Payments} from "@filecoin-pay/Payments.sol"; + +contract FilBeamOperator is Ownable { + struct DataSetUsage { + uint256 cdnAmount; + uint256 cacheMissAmount; + uint256 maxReportedEpoch; + } + + address public fwssContractAddress; + address public immutable paymentsContractAddress; + uint256 public immutable cdnRatePerByte; + uint256 public immutable cacheMissRatePerByte; + address public filBeamOperatorController; + + mapping(uint256 => DataSetUsage) public dataSetUsage; + + event UsageReported( + uint256 indexed dataSetId, + uint256 indexed fromEpoch, + uint256 indexed toEpoch, + uint256 cdnBytesUsed, + uint256 cacheMissBytesUsed + ); + + event CDNSettlement(uint256 indexed dataSetId, uint256 cdnAmount); + + event CacheMissSettlement(uint256 indexed dataSetId, uint256 cacheMissAmount); + + event PaymentRailsTerminated(uint256 indexed dataSetId); + + event FilBeamControllerUpdated(address indexed oldController, address indexed newController); + + /// @notice Initializes the FilBeamOperator contract + /// @param fwssAddress Address of the FWSS contract + /// @param _paymentsAddress Address of the Payments contract + /// @param _cdnRatePerByte CDN rate per byte in smallest token units + /// @param _cacheMissRatePerByte Cache miss rate per byte in smallest token units + /// @param _filBeamOperatorController Address authorized to record usage and terminate payment rails + constructor( + address fwssAddress, + address _paymentsAddress, + uint256 _cdnRatePerByte, + uint256 _cacheMissRatePerByte, + address _filBeamOperatorController + ) Ownable(msg.sender) { + if (fwssAddress == address(0)) revert InvalidAddress(); + if (_paymentsAddress == address(0)) revert InvalidAddress(); + if (_cdnRatePerByte == 0 || _cacheMissRatePerByte == 0) revert InvalidRate(); + if (_filBeamOperatorController == address(0)) revert InvalidAddress(); + + fwssContractAddress = fwssAddress; + paymentsContractAddress = _paymentsAddress; + cdnRatePerByte = _cdnRatePerByte; + cacheMissRatePerByte = _cacheMissRatePerByte; + filBeamOperatorController = _filBeamOperatorController; + } + + modifier onlyFilBeamOperatorController() { + if (msg.sender != filBeamOperatorController) revert Unauthorized(); + _; + } + + /// @notice Records usage rollups for multiple data sets + /// @dev Can only be called by the FilBeam operator controller + /// @param toEpoch Epoch number up to which usage is reported for all data sets + /// @param dataSetIds Array of data set IDs + /// @param cdnBytesUsed Array of CDN egress bytes used for each data set + /// @param cacheMissBytesUsed Array of cache miss egress bytes used for each data set + function recordUsageRollups( + uint256 toEpoch, + uint256[] calldata dataSetIds, + uint256[] calldata cdnBytesUsed, + uint256[] calldata cacheMissBytesUsed + ) external onlyFilBeamOperatorController { + uint256 length = dataSetIds.length; + if (length != cdnBytesUsed.length || length != cacheMissBytesUsed.length) { + revert InvalidUsageAmount(); + } + + for (uint256 i = 0; i < length; i++) { + _recordUsageRollup(dataSetIds[i], toEpoch, cdnBytesUsed[i], cacheMissBytesUsed[i]); + } + } + + /// @notice Settles CDN payment rails for multiple data sets + /// @dev Anyone can call this function to trigger settlement + /// @param dataSetIds Array of data set IDs to settle + function settleCDNPaymentRails(uint256[] calldata dataSetIds) external { + for (uint256 i = 0; i < dataSetIds.length; i++) { + _settlePaymentRail(dataSetIds[i], true); + } + } + + /// @notice Settles cache miss payment rails for multiple data sets + /// @dev Anyone can call this function to trigger settlement + /// @param dataSetIds Array of data set IDs to settle + function settleCacheMissPaymentRails(uint256[] calldata dataSetIds) external { + for (uint256 i = 0; i < dataSetIds.length; i++) { + _settlePaymentRail(dataSetIds[i], false); + } + } + + /// @notice Terminates CDN payment rails for a data set + /// @dev Can only be called by the FilBeam operator controller + /// @param dataSetId The data set ID to terminate payment rails for + function terminateCDNPaymentRails(uint256 dataSetId) external onlyFilBeamOperatorController { + IFWSS(fwssContractAddress).terminateCDNPaymentRails(dataSetId); + + emit PaymentRailsTerminated(dataSetId); + } + + /// @notice Updates the FilBeamOperator controller address + /// @dev Can only be called by the contract owner + /// @param _filBeamOperatorController New controller address + function setFilBeamOperatorController(address _filBeamOperatorController) external onlyOwner { + if (_filBeamOperatorController == address(0)) revert InvalidAddress(); + + address oldController = filBeamOperatorController; + filBeamOperatorController = _filBeamOperatorController; + + emit FilBeamControllerUpdated(oldController, _filBeamOperatorController); + } + + /// @dev Internal function to record usage for a single data set + /// @param dataSetId The data set ID + /// @param toEpoch The epoch number to record usage for + /// @param cdnBytesUsed CDN egress bytes used + /// @param cacheMissBytesUsed Cache miss egress bytes used + function _recordUsageRollup(uint256 dataSetId, uint256 toEpoch, uint256 cdnBytesUsed, uint256 cacheMissBytesUsed) + internal + { + if (toEpoch == 0) revert InvalidEpoch(); + + DataSetUsage storage usage = dataSetUsage[dataSetId]; + + if (toEpoch <= usage.maxReportedEpoch) revert InvalidEpoch(); + + uint256 fromEpoch = usage.maxReportedEpoch + 1; + + // Calculate amounts using current rates at report time + uint256 cdnAmount = cdnBytesUsed * cdnRatePerByte; + uint256 cacheMissAmount = cacheMissBytesUsed * cacheMissRatePerByte; + + usage.cdnAmount += cdnAmount; + usage.cacheMissAmount += cacheMissAmount; + usage.maxReportedEpoch = toEpoch; + + emit UsageReported(dataSetId, fromEpoch, toEpoch, cdnBytesUsed, cacheMissBytesUsed); + } + + /// @dev Internal function to settle a payment rail (CDN or cache miss) + /// @param dataSetId The data set ID to settle + /// @param isCDN True for CDN rail, false for cache miss rail + function _settlePaymentRail(uint256 dataSetId, bool isCDN) internal { + DataSetUsage storage usage = dataSetUsage[dataSetId]; + + // Get the appropriate amount based on rail type + uint256 amount = isCDN ? usage.cdnAmount : usage.cacheMissAmount; + + // Early return if data set not initialized or no usage to settle + if (usage.maxReportedEpoch == 0 || amount == 0) { + return; + } + + // Get rail ID from FWSS + IFWSS.DataSetInfo memory dsInfo = IFWSS(fwssContractAddress).getDataSetInfo(dataSetId); + uint256 railId = isCDN ? dsInfo.cdnRailId : dsInfo.cacheMissRailId; + + // Early return if no rail configured + if (railId == 0) { + return; + } + + // Get the actual amount we can settle based on rail lockup + uint256 amountToSettle = _getSettleableAmount(railId, amount); + + // Early return if nothing can be settled (no lockup available) + if (amountToSettle == 0) { + return; + } + + // Settle the amount through FWSS + if (isCDN) { + IFWSS(fwssContractAddress).settleFilBeamPaymentRails(dataSetId, amountToSettle, 0); + usage.cdnAmount -= amountToSettle; + emit CDNSettlement(dataSetId, amountToSettle); + } else { + IFWSS(fwssContractAddress).settleFilBeamPaymentRails(dataSetId, 0, amountToSettle); + usage.cacheMissAmount -= amountToSettle; + emit CacheMissSettlement(dataSetId, amountToSettle); + } + } + + /// @dev Internal helper to get the settleable amount based on rail lockup + /// @param railId The payment rail ID + /// @param requestedAmount The amount requested to settle + /// @return The amount that can be settled (limited by lockupFixed) + function _getSettleableAmount(uint256 railId, uint256 requestedAmount) internal view returns (uint256) { + Payments.RailView memory rail = Payments(paymentsContractAddress).getRail(railId); + // Return the minimum of requested amount and available lockup + return requestedAmount > rail.lockupFixed ? rail.lockupFixed : requestedAmount; + } +} diff --git a/src/interfaces/IFWSS.sol b/src/interfaces/IFWSS.sol new file mode 100644 index 0000000..8888bd8 --- /dev/null +++ b/src/interfaces/IFWSS.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +interface IFWSS { + struct DataSetInfo { + uint256 pdpRailId; // ID of the PDP payment rail + uint256 cacheMissRailId; // For CDN add-on: ID of the cache miss payment rail + uint256 cdnRailId; // For CDN add-on: ID of the CDN payment rail + address payer; // Address paying for storage + address payee; // SP's beneficiary address + address serviceProvider; // Current service provider of the dataset + uint256 commissionBps; // Commission rate for this data set + uint256 clientDataSetId; // ClientDataSetID + uint256 pdpEndEpoch; // 0 if PDP rail are not terminated + uint256 providerId; // Provider ID from the ServiceProviderRegistry + } + + function getDataSetInfo(uint256 dataSetId) external view returns (DataSetInfo memory); + + function settleFilBeamPaymentRails(uint256 dataSetId, uint256 cdnAmount, uint256 cacheMissAmount) external; + + function terminateCDNPaymentRails(uint256 dataSetId) external; + + function usdfcTokenAddress() external view returns (address); +} diff --git a/src/mocks/MockFWSS.sol b/src/mocks/MockFWSS.sol new file mode 100644 index 0000000..52e509e --- /dev/null +++ b/src/mocks/MockFWSS.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "../interfaces/IFWSS.sol"; + +contract MockFWSS is IFWSS { + struct Settlement { + uint256 dataSetId; + uint256 cdnAmount; + uint256 cacheMissAmount; + uint256 timestamp; + } + + Settlement[] public settlements; + mapping(uint256 => bool) public terminatedDataSets; + mapping(uint256 => DataSetInfo) public dataSetInfos; + address public authorizedCaller; + address public usdfcTokenAddress; + + event PaymentRailsSettled(uint256 indexed dataSetId, uint256 cdnAmount, uint256 cacheMissAmount); + event PaymentRailsTerminated(uint256 indexed dataSetId); + + error UnauthorizedCaller(); + + modifier onlyAuthorized() { + if (msg.sender != authorizedCaller) revert UnauthorizedCaller(); + _; + } + + constructor() { + authorizedCaller = msg.sender; + } + + function setAuthorizedCaller(address caller) external { + authorizedCaller = caller; + } + + function setUsdfcTokenAddress(address _usdfcTokenAddress) external { + usdfcTokenAddress = _usdfcTokenAddress; + } + + function settleFilBeamPaymentRails(uint256 dataSetId, uint256 cdnAmount, uint256 cacheMissAmount) + external + onlyAuthorized + { + settlements.push( + Settlement({ + dataSetId: dataSetId, cdnAmount: cdnAmount, cacheMissAmount: cacheMissAmount, timestamp: block.timestamp + }) + ); + + emit PaymentRailsSettled(dataSetId, cdnAmount, cacheMissAmount); + } + + function terminateCDNPaymentRails(uint256 dataSetId) external onlyAuthorized { + terminatedDataSets[dataSetId] = true; + emit PaymentRailsTerminated(dataSetId); + } + + function getSettlementsCount() external view returns (uint256) { + return settlements.length; + } + + function getSettlement(uint256 index) + external + view + returns (uint256 dataSetId, uint256 cdnAmount, uint256 cacheMissAmount, uint256 timestamp) + { + Settlement storage settlement = settlements[index]; + return (settlement.dataSetId, settlement.cdnAmount, settlement.cacheMissAmount, settlement.timestamp); + } + + function getDataSetInfo(uint256 dataSetId) external view returns (DataSetInfo memory) { + return dataSetInfos[dataSetId]; + } + + function setDataSetInfo(uint256 dataSetId, DataSetInfo memory info) external { + dataSetInfos[dataSetId] = info; + } +} diff --git a/src/mocks/MockPayments.sol b/src/mocks/MockPayments.sol new file mode 100644 index 0000000..3e99d72 --- /dev/null +++ b/src/mocks/MockPayments.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockPayments { + struct RailView { + IERC20 token; + address from; + address to; + address operator; + address validator; + uint256 paymentRate; + uint256 lockupPeriod; + uint256 lockupFixed; + uint256 settledUpTo; + uint256 endEpoch; + uint256 commissionRateBps; + address serviceFeeRecipient; + } + + mapping(uint256 => RailView) public rails; + mapping(uint256 => bool) public railExists; + + function setRail(uint256 railId, RailView memory rail) external { + rails[railId] = rail; + railExists[railId] = true; + } + + function getRail(uint256 railId) external view returns (RailView memory) { + require(railExists[railId], "Rail does not exist"); + return rails[railId]; + } + + function setLockupFixed(uint256 railId, uint256 lockupFixed) external { + require(railExists[railId], "Rail does not exist"); + rails[railId].lockupFixed = lockupFixed; + } + + function setRailExists(uint256 railId, bool exists) external { + railExists[railId] = exists; + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 4831910..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/DeployFilBeamDecimalPricing.t.sol b/test/DeployFilBeamDecimalPricing.t.sol new file mode 100644 index 0000000..8de4c00 --- /dev/null +++ b/test/DeployFilBeamDecimalPricing.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import "../script/DeployFilBeamOperator.s.sol"; + +// Test contract that exposes the internal function +contract TestableDeployFilBeamOperator is DeployFilBeamOperator { + function calculateUsdfcPerBytePublic(uint256 usdPerTibScaled, uint8 priceDecimals, uint8 tokenDecimals) + public + pure + returns (uint256) + { + return calculateUsdfcPerByte(usdPerTibScaled, priceDecimals, tokenDecimals); + } +} + +contract DeployFilBeamDecimalPricingTest is Test { + TestableDeployFilBeamOperator deployer; + + uint256 constant BYTES_PER_TIB = 1024 * 1024 * 1024 * 1024; // 1 TiB in bytes + + function setUp() public { + deployer = new TestableDeployFilBeamOperator(); + } + + function test_calculateUsdfcPerByte_WholeNumbers() public view { + // Test with whole numbers (backward compatibility) + uint256 usdPerTibScaled = 10; // $10/TiB + uint8 priceDecimals = 0; // No decimal places + uint8 tokenDecimals = 6; // USDFC has 6 decimals + + uint256 expected = (10 * (10 ** 6)) / BYTES_PER_TIB; // 10 * 10^6 / 2^40 + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_TwoDecimals() public view { + // Test with 2 decimal places + uint256 usdPerTibScaled = 1250; // $12.50/TiB (1250 with 2 decimals) + uint8 priceDecimals = 2; + uint8 tokenDecimals = 6; // USDFC has 6 decimals + + // Expected: (1250 * 10^6) / 10^2 / 2^40 = (12.50 * 10^6) / 2^40 + uint256 expected = (1250 * (10 ** 6)) / (10 ** 2) / BYTES_PER_TIB; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_ThreeDecimals() public view { + // Test with 3 decimal places + uint256 usdPerTibScaled = 12750; // $12.750/TiB (12750 with 3 decimals) + uint8 priceDecimals = 3; + uint8 tokenDecimals = 6; // USDFC has 6 decimals + + // Expected: (12750 * 10^6) / 10^3 / 2^40 = (12.750 * 10^6) / 2^40 + uint256 expected = (12750 * (10 ** 6)) / (10 ** 3) / BYTES_PER_TIB; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_HighPrecision() public view { + // Test with high precision pricing + uint256 usdPerTibScaled = 999999; // $9.99999/TiB (999999 with 5 decimals) + uint8 priceDecimals = 5; + uint8 tokenDecimals = 18; // Test with 18 decimal token + + uint256 expected = (999999 * (10 ** 18)) / (10 ** 5) / BYTES_PER_TIB; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_LowPriceHighDecimals() public view { + // Test edge case: very low price with high decimals + uint256 usdPerTibScaled = 1; // $0.01/TiB (1 with 2 decimals) + uint8 priceDecimals = 2; + uint8 tokenDecimals = 6; + + uint256 expected = (1 * (10 ** 6)) / (10 ** 2) / BYTES_PER_TIB; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_ZeroDecimals() public view { + // Test with zero decimals (same as whole numbers) + uint256 usdPerTibScaled = 15; // $15/TiB + uint8 priceDecimals = 0; + uint8 tokenDecimals = 6; + + uint256 expected = (15 * (10 ** 6)) / BYTES_PER_TIB; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, tokenDecimals); + + assertEq(result, expected); + } + + function test_calculateUsdfcPerByte_CommonDecimalValues() public view { + // Test common decimal values + uint8 tokenDecimals = 6; + + // Test $12.50 + uint256 result1 = deployer.calculateUsdfcPerBytePublic(1250, 2, tokenDecimals); + uint256 expected1 = (1250 * (10 ** tokenDecimals)) / (10 ** 2) / BYTES_PER_TIB; + assertEq(result1, expected1, "Failed for $12.50"); + + // Test $5.75 + uint256 result2 = deployer.calculateUsdfcPerBytePublic(575, 2, tokenDecimals); + uint256 expected2 = (575 * (10 ** tokenDecimals)) / (10 ** 2) / BYTES_PER_TIB; + assertEq(result2, expected2, "Failed for $5.75"); + + // Test $9.99 + uint256 result3 = deployer.calculateUsdfcPerBytePublic(999, 2, tokenDecimals); + uint256 expected3 = (999 * (10 ** tokenDecimals)) / (10 ** 2) / BYTES_PER_TIB; + assertEq(result3, expected3, "Failed for $9.99"); + + // Test $2.500 (3 decimals) + uint256 result4 = deployer.calculateUsdfcPerBytePublic(2500, 3, tokenDecimals); + uint256 expected4 = (2500 * (10 ** tokenDecimals)) / (10 ** 3) / BYTES_PER_TIB; + assertEq(result4, expected4, "Failed for $2.500"); + + // Test $7.5 (1 decimal) + uint256 result5 = deployer.calculateUsdfcPerBytePublic(75, 1, tokenDecimals); + uint256 expected5 = (75 * (10 ** tokenDecimals)) / (10 ** 1) / BYTES_PER_TIB; + assertEq(result5, expected5, "Failed for $7.5"); + } + + function test_calculateUsdfcPerByte_DifferentTokenDecimals() public view { + // Test with different token decimal configurations + uint256 usdPerTibScaled = 1275; // $12.75 + uint8 priceDecimals = 2; + + // Test various token decimals + uint8[5] memory tokenDecimals = [uint8(6), uint8(8), uint8(12), uint8(18), uint8(2)]; + + for (uint256 i = 0; i < tokenDecimals.length; i++) { + uint8 decimals = tokenDecimals[i]; + uint256 result = deployer.calculateUsdfcPerBytePublic(usdPerTibScaled, priceDecimals, decimals); + uint256 expected = (1275 * (10 ** decimals)) / (10 ** 2) / BYTES_PER_TIB; + + assertEq(result, expected, string.concat("Failed for token decimals: ", vm.toString(decimals))); + } + } + + function testFuzz_calculateUsdfcPerByte(uint128 scaledPrice, uint8 priceDecimals, uint8 tokenDecimals) public view { + // Bound inputs to reasonable ranges + scaledPrice = uint128(bound(scaledPrice, 1, type(uint64).max)); // Reasonable price range + priceDecimals = uint8(bound(priceDecimals, 0, 12)); // Limit to reasonable decimal range + tokenDecimals = uint8(bound(tokenDecimals, 0, 18)); // Standard token decimal range + + uint256 result = deployer.calculateUsdfcPerBytePublic(scaledPrice, priceDecimals, tokenDecimals); + + // Verify the calculation manually + uint256 expected = (uint256(scaledPrice) * (10 ** tokenDecimals)) / (10 ** priceDecimals) / BYTES_PER_TIB; + assertEq(result, expected, "Fuzz test calculation mismatch"); + + // Basic sanity check: result should be non-zero if the calculation doesn't underflow + uint256 usdfcPerTib = (uint256(scaledPrice) * (10 ** tokenDecimals)) / (10 ** priceDecimals); + if (usdfcPerTib >= BYTES_PER_TIB) { + assertGt(result, 0, "Result should be non-zero when calculation doesn't underflow"); + } + } +} diff --git a/test/FilBeamOperator.t.sol b/test/FilBeamOperator.t.sol new file mode 100644 index 0000000..9d0e70a --- /dev/null +++ b/test/FilBeamOperator.t.sol @@ -0,0 +1,1282 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {FilBeamOperator} from "../src/FilBeamOperator.sol"; +import {IFWSS} from "../src/interfaces/IFWSS.sol"; +import {MockFWSS} from "../src/mocks/MockFWSS.sol"; +import {MockPayments} from "../src/mocks/MockPayments.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../src/Errors.sol"; + +contract FilBeamOperatorTest is Test { + FilBeamOperator public filBeam; + MockFWSS public mockFWSS; + MockPayments public mockPayments; + address public owner; + address public filBeamOperatorController; + address public user1; + address public user2; + + uint256 constant DATA_SET_ID_1 = 1; + uint256 constant DATA_SET_ID_2 = 2; + uint256 constant CDN_RATE_PER_BYTE = 100; + uint256 constant CACHE_MISS_RATE_PER_BYTE = 200; + + event UsageReported( + uint256 indexed dataSetId, + uint256 indexed fromEpoch, + uint256 indexed toEpoch, + uint256 cdnBytesUsed, + uint256 cacheMissBytesUsed + ); + + event CDNSettlement(uint256 indexed dataSetId, uint256 cdnAmount); + + event CacheMissSettlement(uint256 indexed dataSetId, uint256 cacheMissAmount); + + event PaymentRailsTerminated(uint256 indexed dataSetId); + + event FilBeamControllerUpdated(address indexed oldController, address indexed newController); + + function setUp() public { + owner = address(this); + filBeamOperatorController = makeAddr("filBeamOperatorController"); + user1 = makeAddr("user1"); + user2 = makeAddr("user2"); + + mockFWSS = new MockFWSS(); + mockPayments = new MockPayments(); + + // Deploy FilBeamOperator contract (deployer becomes owner) + filBeam = new FilBeamOperator( + address(mockFWSS), + address(mockPayments), + CDN_RATE_PER_BYTE, + CACHE_MISS_RATE_PER_BYTE, + filBeamOperatorController + ); + + mockFWSS.setAuthorizedCaller(address(filBeam)); + + // Set up default rails for testing + _setupDefaultRails(); + } + + function _setupDefaultRails() internal { + // Set up CDN rail for DATA_SET_ID_1 + MockPayments.RailView memory cdnRail = MockPayments.RailView({ + token: IERC20(address(0)), + from: user1, + to: user2, + operator: address(0), + validator: address(0), + paymentRate: 100, + lockupPeriod: 100, + lockupFixed: 1000000, // 1M default lockup + settledUpTo: 0, + endEpoch: 0, + commissionRateBps: 0, + serviceFeeRecipient: address(0) + }); + mockPayments.setRail(1, cdnRail); + + // Set up cache miss rail for DATA_SET_ID_1 + MockPayments.RailView memory cacheMissRail = MockPayments.RailView({ + token: IERC20(address(0)), + from: user1, + to: user2, + operator: address(0), + validator: address(0), + paymentRate: 200, + lockupPeriod: 100, + lockupFixed: 1000000, // 1M default lockup + settledUpTo: 0, + endEpoch: 0, + commissionRateBps: 0, + serviceFeeRecipient: address(0) + }); + mockPayments.setRail(2, cacheMissRail); + + // Set up CDN rail for DATA_SET_ID_2 + MockPayments.RailView memory cdnRail2 = MockPayments.RailView({ + token: IERC20(address(0)), + from: user1, + to: user2, + operator: address(0), + validator: address(0), + paymentRate: 100, + lockupPeriod: 100, + lockupFixed: 1000000, // 1M default lockup + settledUpTo: 0, + endEpoch: 0, + commissionRateBps: 0, + serviceFeeRecipient: address(0) + }); + mockPayments.setRail(3, cdnRail2); + + // Set up cache miss rail for DATA_SET_ID_2 + MockPayments.RailView memory cacheMissRail2 = MockPayments.RailView({ + token: IERC20(address(0)), + from: user1, + to: user2, + operator: address(0), + validator: address(0), + paymentRate: 200, + lockupPeriod: 100, + lockupFixed: 1000000, // 1M default lockup + settledUpTo: 0, + endEpoch: 0, + commissionRateBps: 0, + serviceFeeRecipient: address(0) + }); + mockPayments.setRail(4, cacheMissRail2); + + // Set up DataSetInfo for DATA_SET_ID_1 + IFWSS.DataSetInfo memory dsInfo = IFWSS.DataSetInfo({ + pdpRailId: 0, + cacheMissRailId: 2, + cdnRailId: 1, + payer: user1, + payee: user2, + serviceProvider: address(0), + commissionBps: 0, + clientDataSetId: 0, + pdpEndEpoch: 0, + providerId: 0 + }); + mockFWSS.setDataSetInfo(DATA_SET_ID_1, dsInfo); + + // Set up DataSetInfo for DATA_SET_ID_2 + IFWSS.DataSetInfo memory dsInfo2 = IFWSS.DataSetInfo({ + pdpRailId: 0, + cacheMissRailId: 4, + cdnRailId: 3, + payer: user1, + payee: user2, + serviceProvider: address(0), + commissionBps: 0, + clientDataSetId: 0, + pdpEndEpoch: 0, + providerId: 0 + }); + mockFWSS.setDataSetInfo(DATA_SET_ID_2, dsInfo2); + } + + // Helper functions to create single-element arrays + function _singleUint256Array(uint256 value) internal pure returns (uint256[] memory) { + uint256[] memory arr = new uint256[](1); + arr[0] = value; + return arr; + } + + function test_Initialize() public view { + assertEq(filBeam.fwssContractAddress(), address(mockFWSS)); + assertEq(filBeam.paymentsContractAddress(), address(mockPayments)); + assertEq(filBeam.owner(), owner); + assertEq(filBeam.filBeamOperatorController(), filBeamOperatorController); + assertEq(filBeam.cdnRatePerByte(), CDN_RATE_PER_BYTE); + assertEq(filBeam.cacheMissRatePerByte(), CACHE_MISS_RATE_PER_BYTE); + } + + function test_InitializeRevertZeroAddress() public { + vm.expectRevert(InvalidAddress.selector); + new FilBeamOperator( + address(0), address(mockPayments), CDN_RATE_PER_BYTE, CACHE_MISS_RATE_PER_BYTE, filBeamOperatorController + ); + + vm.expectRevert(InvalidAddress.selector); + new FilBeamOperator( + address(mockFWSS), address(0), CDN_RATE_PER_BYTE, CACHE_MISS_RATE_PER_BYTE, filBeamOperatorController + ); + } + + function test_InitializeRevertZeroRate() public { + vm.expectRevert(InvalidRate.selector); + new FilBeamOperator( + address(mockFWSS), address(mockPayments), 0, CACHE_MISS_RATE_PER_BYTE, filBeamOperatorController + ); + + vm.expectRevert(InvalidRate.selector); + new FilBeamOperator(address(mockFWSS), address(mockPayments), CDN_RATE_PER_BYTE, 0, filBeamOperatorController); + } + + function test_InitializeRevertZeroFilBeamController() public { + vm.expectRevert(InvalidAddress.selector); + new FilBeamOperator( + address(mockFWSS), address(mockPayments), CDN_RATE_PER_BYTE, CACHE_MISS_RATE_PER_BYTE, address(0) + ); + } + + function test_ReportUsageRollup() public { + vm.expectEmit(true, true, true, true); + emit UsageReported(DATA_SET_ID_1, 1, 1, 1000, 500); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + (uint256 cdnAmount, uint256 cacheMissAmount, uint256 maxReportedEpoch) = filBeam.dataSetUsage(DATA_SET_ID_1); + + assertEq(cdnAmount, 1000 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount, 500 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxReportedEpoch, 1); + } + + function test_ReportUsageRollupMultipleEpochs() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + filBeam.recordUsageRollups( + 3, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1500), _singleUint256Array(750) + ); + vm.stopPrank(); + + (uint256 cdnAmount, uint256 cacheMissAmount, uint256 maxReportedEpoch) = filBeam.dataSetUsage(DATA_SET_ID_1); + + assertEq(cdnAmount, 4500 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount, 2250 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxReportedEpoch, 3); + } + + function test_ReportUsageRollupRevertUnauthorized() public { + vm.prank(user1); + vm.expectRevert(Unauthorized.selector); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + } + + function test_ReportUsageRollupRevertZeroEpoch() public { + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups( + 0, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + } + + function test_ReportUsageRollupRevertDuplicateEpoch() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + } + + function test_ReportUsageRollupRevertInvalidEpochOrder() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 3, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + } + + function test_SettleCDNPaymentRail() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + vm.stopPrank(); + + vm.expectEmit(true, false, false, true); + emit CDNSettlement(DATA_SET_ID_1, 300000); + + vm.prank(user1); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (uint256 cdnAmount, uint256 cacheMissAmount, uint256 maxReportedEpoch) = filBeam.dataSetUsage(DATA_SET_ID_1); + + assertEq(cdnAmount, 0); + assertEq(cacheMissAmount, 1500 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxReportedEpoch, 2); + + assertEq(mockFWSS.getSettlementsCount(), 1); + (uint256 dataSetId, uint256 settledCdnAmount, uint256 settledCacheMissAmount,) = mockFWSS.getSettlement(0); + assertEq(dataSetId, DATA_SET_ID_1); + assertEq(settledCdnAmount, 300000); + assertEq(settledCacheMissAmount, 0); + } + + function test_SettleCacheMissPaymentRail() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + vm.stopPrank(); + + vm.expectEmit(true, false, false, true); + emit CacheMissSettlement(DATA_SET_ID_1, 300000); + + vm.prank(user1); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (uint256 cdnAmount, uint256 cacheMissAmount, uint256 maxReportedEpoch) = filBeam.dataSetUsage(DATA_SET_ID_1); + + assertEq(cdnAmount, 3000 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount, 0); + assertEq(maxReportedEpoch, 2); + + assertEq(mockFWSS.getSettlementsCount(), 1); + (uint256 dataSetId, uint256 settledCdnAmount, uint256 settledCacheMissAmount,) = mockFWSS.getSettlement(0); + assertEq(dataSetId, DATA_SET_ID_1); + assertEq(settledCdnAmount, 0); + assertEq(settledCacheMissAmount, 300000); + } + + function test_SettlementDataSetNotInitialized() public { + // Should not revert, just return early without emitting events + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Verify no settlements were made + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_SettlementNoUsageToSettle() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 1); + + // Should not revert, just return early without additional settlements + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 1); // Still 1, no new settlement + + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 2); + + // Should not revert, just return early without additional settlements + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 2); // Still 2, no new settlement + } + + function test_TerminateCDNPaymentRails() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + vm.expectEmit(true, false, false, false); + emit PaymentRailsTerminated(DATA_SET_ID_1); + + vm.prank(filBeamOperatorController); + filBeam.terminateCDNPaymentRails(DATA_SET_ID_1); + + assertTrue(mockFWSS.terminatedDataSets(DATA_SET_ID_1)); + } + + function test_TerminateCDNPaymentRailsRevertUnauthorized() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + vm.prank(user1); + vm.expectRevert(Unauthorized.selector); + filBeam.terminateCDNPaymentRails(DATA_SET_ID_1); + } + + function test_TransferOwnership() public { + filBeam.transferOwnership(user1); + assertEq(filBeam.owner(), user1); + + vm.prank(user1); + filBeam.transferOwnership(user2); + assertEq(filBeam.owner(), user2); + } + + function test_TransferOwnershipRevertOnlyOwner() public { + vm.prank(user1); + vm.expectRevert(); + filBeam.transferOwnership(user2); + } + + function test_TransferOwnershipRevertZeroAddress() public { + vm.expectRevert(); + filBeam.transferOwnership(address(0)); + } + + function test_MultipleDataSets() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_2), _singleUint256Array(2000), _singleUint256Array(1000) + ); + vm.stopPrank(); + + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_2)); + + assertEq(mockFWSS.getSettlementsCount(), 2); + + (uint256 settledDataSetId1, uint256 settledCdnAmount1, uint256 settledCacheMissAmount1,) = + mockFWSS.getSettlement(0); + assertEq(settledDataSetId1, DATA_SET_ID_1); + assertEq(settledCdnAmount1, 100000); + assertEq(settledCacheMissAmount1, 0); + + (uint256 settledDataSetId2, uint256 settledCdnAmount2, uint256 settledCacheMissAmount2,) = + mockFWSS.getSettlement(1); + assertEq(settledDataSetId2, DATA_SET_ID_2); + assertEq(settledCdnAmount2, 0); + assertEq(settledCacheMissAmount2, 200000); + } + + function test_PartialSettlement() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + vm.stopPrank(); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 3, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1500), _singleUint256Array(750) + ); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + assertEq(mockFWSS.getSettlementsCount(), 2); + + (,, uint256 maxReportedEpoch) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(maxReportedEpoch, 3); + } + + function testFuzz_ReportUsageRollup(uint256 dataSetId, uint256 epoch, uint256 cdnBytes, uint256 cacheMissBytes) + public + { + vm.assume(dataSetId != 0); + vm.assume(epoch > 0 && epoch < type(uint256).max); + vm.assume(cdnBytes < type(uint256).max / CDN_RATE_PER_BYTE); + vm.assume(cacheMissBytes < type(uint256).max / CACHE_MISS_RATE_PER_BYTE); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + epoch, _singleUint256Array(dataSetId), _singleUint256Array(cdnBytes), _singleUint256Array(cacheMissBytes) + ); + + (uint256 cdnAmount, uint256 cacheMissAmount, uint256 maxReportedEpoch) = filBeam.dataSetUsage(dataSetId); + + assertEq(cdnAmount, cdnBytes * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount, cacheMissBytes * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxReportedEpoch, epoch); + } + + function test_ZeroUsageReporting() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(0), _singleUint256Array(0) + ); + + // Should not emit event when amount is 0 (early return) + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // No external call should be made when amount is 0 + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_IndependentSettlement() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + filBeam.recordUsageRollups( + 3, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1500), _singleUint256Array(750) + ); + vm.stopPrank(); + + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (, uint256 cacheMissAmount1, uint256 maxReportedEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cacheMissAmount1, 2250 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxReportedEpoch1, 3); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 4, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(800), _singleUint256Array(400) + ); + + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (uint256 cdnAmount2, uint256 cacheMissAmount2, uint256 maxReportedEpoch2) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount2, 800 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount2, 0); + assertEq(maxReportedEpoch2, 4); + + assertEq(mockFWSS.getSettlementsCount(), 2); + + (uint256 settledDataSetId1, uint256 settledCdnAmount1, uint256 settledCacheMissAmount1,) = + mockFWSS.getSettlement(0); + assertEq(settledDataSetId1, DATA_SET_ID_1); + assertEq(settledCdnAmount1, 450000); + assertEq(settledCacheMissAmount1, 0); + + (uint256 settledDataSetId2, uint256 settledCdnAmount2, uint256 settledCacheMissAmount2,) = + mockFWSS.getSettlement(1); + assertEq(settledDataSetId2, DATA_SET_ID_1); + assertEq(settledCdnAmount2, 0); + assertEq(settledCacheMissAmount2, 530000); + } + + function test_RateCalculations() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + (, uint256 settledCdnAmount1, uint256 settledCacheMissAmount1,) = mockFWSS.getSettlement(0); + assertEq(settledCdnAmount1, 1000 * CDN_RATE_PER_BYTE); + assertEq(settledCacheMissAmount1, 0); + + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + (, uint256 settledCdnAmount2, uint256 settledCacheMissAmount2,) = mockFWSS.getSettlement(1); + assertEq(settledCdnAmount2, 0); + assertEq(settledCacheMissAmount2, 500 * CACHE_MISS_RATE_PER_BYTE); + } + + function test_ReportUsageRollupBatch() public { + uint256[] memory dataSetIds = new uint256[](2); + uint256[] memory cdnBytesUsed = new uint256[](2); + uint256[] memory cacheMissBytesUsed = new uint256[](2); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 1000; + cacheMissBytesUsed[0] = 500; + + dataSetIds[1] = DATA_SET_ID_2; + cdnBytesUsed[1] = 1500; + cacheMissBytesUsed[1] = 750; + + vm.expectEmit(true, true, true, true); + emit UsageReported(DATA_SET_ID_1, 1, 1, 1000, 500); + vm.expectEmit(true, true, true, true); + emit UsageReported(DATA_SET_ID_2, 1, 1, 1500, 750); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups(1, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + + (uint256 cdnAmount1, uint256 cacheMissAmount1, uint256 maxEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 1000 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount1, 500 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxEpoch1, 1); + + // Report epoch 2 for DATA_SET_ID_1 + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + + (uint256 cdnAmount1_v2, uint256 cacheMissAmount1_v2, uint256 maxEpoch1_v2) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1_v2, 3000 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount1_v2, 1500 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxEpoch1_v2, 2); + + (uint256 cdnAmount2, uint256 cacheMissAmount2, uint256 maxEpoch2) = filBeam.dataSetUsage(DATA_SET_ID_2); + assertEq(cdnAmount2, 1500 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount2, 750 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxEpoch2, 1); + } + + function test_ReportUsageRollupBatchRevertArrayLengthMismatch() public { + uint256[] memory dataSetIds = new uint256[](2); + uint256[] memory cdnBytesUsed = new uint256[](3); + uint256[] memory cacheMissBytesUsed = new uint256[](2); + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidUsageAmount.selector); + filBeam.recordUsageRollups(1, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchRevertUnauthorized() public { + uint256[] memory dataSetIds = new uint256[](1); + uint256[] memory cdnBytesUsed = new uint256[](1); + uint256[] memory cacheMissBytesUsed = new uint256[](1); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 1000; + cacheMissBytesUsed[0] = 500; + + vm.prank(user1); + vm.expectRevert(Unauthorized.selector); + filBeam.recordUsageRollups(1, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchRevertZeroEpoch() public { + uint256[] memory dataSetIds = new uint256[](1); + uint256[] memory cdnBytesUsed = new uint256[](1); + uint256[] memory cacheMissBytesUsed = new uint256[](1); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 1000; + cacheMissBytesUsed[0] = 500; + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups(0, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchRevertDuplicateEpoch() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + uint256[] memory dataSetIds = new uint256[](1); + uint256[] memory cdnBytesUsed = new uint256[](1); + uint256[] memory cacheMissBytesUsed = new uint256[](1); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 2000; + cacheMissBytesUsed[0] = 1000; + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups(1, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchRevertInvalidEpochOrder() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 3, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + uint256[] memory dataSetIds = new uint256[](1); + uint256[] memory cdnBytesUsed = new uint256[](1); + uint256[] memory cacheMissBytesUsed = new uint256[](1); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 2000; + cacheMissBytesUsed[0] = 1000; + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups(2, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchEmptyArrays() public { + uint256[] memory dataSetIds = new uint256[](0); + uint256[] memory cdnBytesUsed = new uint256[](0); + uint256[] memory cacheMissBytesUsed = new uint256[](0); + + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups(1, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + } + + function test_ReportUsageRollupBatchWithSettlement() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + vm.stopPrank(); + + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + assertEq(mockFWSS.getSettlementsCount(), 1); + (uint256 dataSetId, uint256 settledCdnAmount, uint256 settledCacheMissAmount,) = mockFWSS.getSettlement(0); + assertEq(dataSetId, DATA_SET_ID_1); + assertEq(settledCdnAmount, 300000); + assertEq(settledCacheMissAmount, 0); + } + + function test_ReportUsageRollupBatchAtomicity() public { + uint256[] memory dataSetIds = new uint256[](3); + uint256[] memory cdnBytesUsed = new uint256[](3); + uint256[] memory cacheMissBytesUsed = new uint256[](3); + + dataSetIds[0] = DATA_SET_ID_1; + cdnBytesUsed[0] = 1000; + cacheMissBytesUsed[0] = 500; + + dataSetIds[1] = DATA_SET_ID_1; + cdnBytesUsed[1] = 2000; + cacheMissBytesUsed[1] = 1000; + + dataSetIds[2] = DATA_SET_ID_1; + cdnBytesUsed[2] = 1500; + cacheMissBytesUsed[2] = 750; + + vm.prank(filBeamOperatorController); + vm.expectRevert(InvalidEpoch.selector); + filBeam.recordUsageRollups(0, dataSetIds, cdnBytesUsed, cacheMissBytesUsed); + + (uint256 cdnAmount1, uint256 cacheMissAmount1, uint256 maxReportedEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + + assertEq(cdnAmount1, 0); + assertEq(cacheMissAmount1, 0); + assertEq(maxReportedEpoch1, 0); + } + + function test_SettleCDNPaymentRailBatch() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_2), _singleUint256Array(1500), _singleUint256Array(750) + ); + vm.stopPrank(); + + uint256[] memory dataSetIds = new uint256[](2); + dataSetIds[0] = DATA_SET_ID_1; + dataSetIds[1] = DATA_SET_ID_2; + + vm.expectEmit(true, false, false, true); + emit CDNSettlement(DATA_SET_ID_1, 300000); + vm.expectEmit(true, false, false, true); + emit CDNSettlement(DATA_SET_ID_2, 150000); + + vm.prank(user1); + filBeam.settleCDNPaymentRails(dataSetIds); + + (uint256 cdnAmount1, uint256 cacheMissAmount1, uint256 maxEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 0); + assertEq(cacheMissAmount1, 1500 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxEpoch1, 2); + + (uint256 cdnAmount2, uint256 cacheMissAmount2, uint256 maxEpoch2) = filBeam.dataSetUsage(DATA_SET_ID_2); + assertEq(cdnAmount2, 0); + assertEq(cacheMissAmount2, 750 * CACHE_MISS_RATE_PER_BYTE); + assertEq(maxEpoch2, 1); + + assertEq(mockFWSS.getSettlementsCount(), 2); + (uint256 settledDataSetId1, uint256 settledCdnAmount1, uint256 settledCacheMissAmount1,) = + mockFWSS.getSettlement(0); + assertEq(settledDataSetId1, DATA_SET_ID_1); + assertEq(settledCdnAmount1, 300000); + assertEq(settledCacheMissAmount1, 0); + + (uint256 settledDataSetId2, uint256 settledCdnAmount2, uint256 settledCacheMissAmount2,) = + mockFWSS.getSettlement(1); + assertEq(settledDataSetId2, DATA_SET_ID_2); + assertEq(settledCdnAmount2, 150000); + assertEq(settledCacheMissAmount2, 0); + } + + function test_SettleCacheMissPaymentRailBatch() public { + vm.startPrank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.recordUsageRollups( + 2, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(2000), _singleUint256Array(1000) + ); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_2), _singleUint256Array(1500), _singleUint256Array(750) + ); + vm.stopPrank(); + + uint256[] memory dataSetIds = new uint256[](2); + dataSetIds[0] = DATA_SET_ID_1; + dataSetIds[1] = DATA_SET_ID_2; + + vm.expectEmit(true, false, false, true); + emit CacheMissSettlement(DATA_SET_ID_1, 300000); + vm.expectEmit(true, false, false, true); + emit CacheMissSettlement(DATA_SET_ID_2, 150000); + + vm.prank(user1); + filBeam.settleCacheMissPaymentRails(dataSetIds); + + (uint256 cdnAmount1, uint256 cacheMissAmount1, uint256 maxEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 3000 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount1, 0); + assertEq(maxEpoch1, 2); + + (uint256 cdnAmount2, uint256 cacheMissAmount2, uint256 maxEpoch2) = filBeam.dataSetUsage(DATA_SET_ID_2); + assertEq(cdnAmount2, 1500 * CDN_RATE_PER_BYTE); + assertEq(cacheMissAmount2, 0); + assertEq(maxEpoch2, 1); + + assertEq(mockFWSS.getSettlementsCount(), 2); + (uint256 settledDataSetId1, uint256 settledCdnAmount1, uint256 settledCacheMissAmount1,) = + mockFWSS.getSettlement(0); + assertEq(settledDataSetId1, DATA_SET_ID_1); + assertEq(settledCdnAmount1, 0); + assertEq(settledCacheMissAmount1, 300000); + + (uint256 settledDataSetId2, uint256 settledCdnAmount2, uint256 settledCacheMissAmount2,) = + mockFWSS.getSettlement(1); + assertEq(settledDataSetId2, DATA_SET_ID_2); + assertEq(settledCdnAmount2, 0); + assertEq(settledCacheMissAmount2, 150000); + } + + function test_SettleCDNPaymentRailBatchEmptyArray() public { + uint256[] memory dataSetIds = new uint256[](0); + filBeam.settleCDNPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_SettleCacheMissPaymentRailBatchEmptyArray() public { + uint256[] memory dataSetIds = new uint256[](0); + filBeam.settleCacheMissPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_SettleCDNPaymentRailBatchDataSetNotInitialized() public { + uint256[] memory dataSetIds = new uint256[](1); + dataSetIds[0] = DATA_SET_ID_1; + + // Should not revert, just return early without settlements + filBeam.settleCDNPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_SettleCacheMissPaymentRailBatchDataSetNotInitialized() public { + uint256[] memory dataSetIds = new uint256[](1); + dataSetIds[0] = DATA_SET_ID_1; + + // Should not revert, just return early without settlements + filBeam.settleCacheMissPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 0); + } + + function test_SettleCDNPaymentRailBatchNoUsageToSettle() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 1); + + uint256[] memory dataSetIds = new uint256[](1); + dataSetIds[0] = DATA_SET_ID_1; + + // Should not revert, just return early without new settlements + filBeam.settleCDNPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 1); // Still 1, no new settlement + } + + function test_SettleCacheMissPaymentRailBatchNoUsageToSettle() public { + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 1); + + uint256[] memory dataSetIds = new uint256[](1); + dataSetIds[0] = DATA_SET_ID_1; + + // Should not revert, just return early without new settlements + filBeam.settleCacheMissPaymentRails(dataSetIds); + assertEq(mockFWSS.getSettlementsCount(), 1); // Still 1, no new settlement + } + + function test_SilentEarlyReturnsNoEvents() public { + // Test 1: Uninitialized dataset should not revert or change state + uint256 initialSettlementCount = mockFWSS.getSettlementsCount(); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), initialSettlementCount, "Should not settle uninitialized dataset"); + + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), initialSettlementCount, "Should not settle uninitialized dataset"); + + // Initialize with usage + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + // Settle once (should work) + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), initialSettlementCount + 1, "Should settle first time"); + + // Test 2: Already settled dataset should not create new settlements + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), initialSettlementCount + 1, "Should not settle when no new usage"); + } + + function test_SettlementBatchMixedInitialization() public { + // Record usage for DATA_SET_ID_1 but not DATA_SET_ID_2 + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + uint256[] memory dataSetIds = new uint256[](2); + dataSetIds[0] = DATA_SET_ID_1; + dataSetIds[1] = DATA_SET_ID_2; // Not initialized + + // Should settle DATA_SET_ID_1 and skip DATA_SET_ID_2 without reverting + filBeam.settleCDNPaymentRails(dataSetIds); + + // Verify DATA_SET_ID_1 was settled + (uint256 cdnAmount1, uint256 cacheMissAmount1, uint256 maxEpoch1) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 0); // Settled, so amount is 0 + assertEq(cacheMissAmount1, 500 * CACHE_MISS_RATE_PER_BYTE); // Not settled yet + assertEq(maxEpoch1, 1); + + assertEq(mockFWSS.getSettlementsCount(), 1); // Only DATA_SET_ID_1 was settled + } + + function test_SetFilBeamController() public { + address newController = makeAddr("newController"); + + vm.expectEmit(true, true, false, true); + emit FilBeamControllerUpdated(filBeamOperatorController, newController); + + filBeam.setFilBeamOperatorController(newController); + + assertEq(filBeam.filBeamOperatorController(), newController); + } + + function test_SetFilBeamControllerRevertUnauthorized() public { + address newController = makeAddr("newController"); + + vm.prank(user1); + vm.expectRevert(); + filBeam.setFilBeamOperatorController(newController); + } + + function test_SetFilBeamControllerRevertZeroAddress() public { + vm.expectRevert(InvalidAddress.selector); + filBeam.setFilBeamOperatorController(address(0)); + } + + function test_SetFilBeamControllerUpdatesAccess() public { + address newController = makeAddr("newController"); + + filBeam.setFilBeamOperatorController(newController); + + vm.prank(filBeamOperatorController); + vm.expectRevert(Unauthorized.selector); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + vm.prank(newController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + (uint256 cdnAmount,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount, 1000 * CDN_RATE_PER_BYTE); + } + + // Test settling accumulated amounts without new usage + function test_SettleAccumulatedAmountWithoutNewUsage() public { + // Simulate partial settlement by manually setting accumulated amount + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, + _singleUint256Array(DATA_SET_ID_1), + _singleUint256Array(2000), // 200k amount + _singleUint256Array(1500) // 300k amount + ); + + // First settlement + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Verify initial settlements + assertEq(mockFWSS.getSettlementsCount(), 2); + + // Manually add accumulated amounts (simulating partial settlement scenario) + // This would happen if the previous settlement was limited by lockupFixed + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 2, + _singleUint256Array(DATA_SET_ID_1), + _singleUint256Array(1000), // Add 100k CDN amount + _singleUint256Array(500) // Add 100k cache miss amount + ); + + // Settle CDN without new usage report + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Verify CDN was settled + assertEq(mockFWSS.getSettlementsCount(), 3); + (uint256 cdnAmount,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount, 0, "CDN amount should be fully settled"); + + // Settle cache miss without new usage report + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Verify cache miss was settled + assertEq(mockFWSS.getSettlementsCount(), 4); + (, uint256 cacheMissAmount,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cacheMissAmount, 0, "Cache miss amount should be fully settled"); + } + + function test_SettlementWithNoRailConfigured() public { + // Set up DATA_SET_ID_2 with no rails + IFWSS.DataSetInfo memory dsInfo = IFWSS.DataSetInfo({ + pdpRailId: 0, + cacheMissRailId: 0, + cdnRailId: 0, + payer: user1, + payee: user2, + serviceProvider: address(0), + commissionBps: 0, + clientDataSetId: 0, + pdpEndEpoch: 0, + providerId: 0 + }); + mockFWSS.setDataSetInfo(DATA_SET_ID_2, dsInfo); + + // Record usage + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_2), _singleUint256Array(1000), _singleUint256Array(500) + ); + + // Try to settle - should not revert or settle + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_2)); + assertEq(mockFWSS.getSettlementsCount(), 0, "Should not settle without rail"); + + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_2)); + assertEq(mockFWSS.getSettlementsCount(), 0, "Should not settle without rail"); + + // Amount should still be accumulated + (uint256 cdnAmount, uint256 cacheMissAmount,) = filBeam.dataSetUsage(DATA_SET_ID_2); + assertEq(cdnAmount, 100000, "Amount should still be accumulated"); + assertEq(cacheMissAmount, 100000, "Amount should still be accumulated"); + } + + // Test partial settlement when lockup is less than accumulated amount + function test_PartialSettlementWithLimitedLockup() public { + // Set limited lockup for CDN rail (less than what will be accumulated) + mockPayments.setLockupFixed(1, 50000); // CDN rail has 50k lockup + mockPayments.setLockupFixed(2, 30000); // Cache miss rail has 30k lockup + + // Record usage that will exceed the lockup limits + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, + _singleUint256Array(DATA_SET_ID_1), + _singleUint256Array(1000), // 100k CDN amount (1000 * 100) + _singleUint256Array(500) // 100k cache miss amount (500 * 200) + ); + + // Check accumulated amounts + (uint256 cdnAmount1, uint256 cacheMissAmount1,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 100000, "Should have 100k CDN amount"); + assertEq(cacheMissAmount1, 100000, "Should have 100k cache miss amount"); + + // First CDN settlement - should only settle 50k due to lockup limit + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Check remaining amount after partial settlement + (uint256 cdnAmount2, uint256 cacheMissAmount2,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount2, 50000, "Should have 50k CDN remaining after partial settlement"); + assertEq(cacheMissAmount2, 100000, "Cache miss amount should be unchanged"); + + // Verify settlement amount + assertEq(mockFWSS.getSettlementsCount(), 1); + (, uint256 settledCdn1,,) = mockFWSS.getSettlement(0); + assertEq(settledCdn1, 50000, "Should have settled 50k CDN"); + + // First cache miss settlement - should only settle 30k due to lockup limit + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Check remaining amount after partial settlement + (uint256 cdnAmount3, uint256 cacheMissAmount3,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount3, 50000, "CDN amount should be unchanged"); + assertEq(cacheMissAmount3, 70000, "Should have 70k cache miss remaining after partial settlement"); + + // Verify settlement amount + assertEq(mockFWSS.getSettlementsCount(), 2); + (,, uint256 settledCacheMiss1,) = mockFWSS.getSettlement(1); + assertEq(settledCacheMiss1, 30000, "Should have settled 30k cache miss"); + + // Second CDN settlement - should settle remaining 50k + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (uint256 cdnAmount4,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount4, 0, "Should have no CDN remaining after second settlement"); + + assertEq(mockFWSS.getSettlementsCount(), 3); + (, uint256 settledCdn2,,) = mockFWSS.getSettlement(2); + assertEq(settledCdn2, 50000, "Should have settled remaining 50k CDN"); + + // Increase lockup and settle remaining cache miss + mockPayments.setLockupFixed(2, 100000); // Increase cache miss rail lockup + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + (, uint256 cacheMissAmount4,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cacheMissAmount4, 0, "Should have no cache miss remaining"); + + assertEq(mockFWSS.getSettlementsCount(), 4); + (,, uint256 settledCacheMiss2,) = mockFWSS.getSettlement(3); + assertEq(settledCacheMiss2, 70000, "Should have settled remaining 70k cache miss"); + } + + function test_SettlementWithZeroLockup() public { + // Set lockup to 0 + mockPayments.setLockupFixed(1, 0); + + // Record usage + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + // Try to settle - should not settle anything due to zero lockup + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 0, "Should not settle with zero lockup"); + + // Amount should still be accumulated + (uint256 cdnAmount,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount, 100000, "Amount should still be accumulated"); + } + + function test_SettlementWithInactiveRail() public { + // Record usage first + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(DATA_SET_ID_1), _singleUint256Array(1000), _singleUint256Array(500) + ); + + // Make rail inactive by removing it + mockPayments.setRailExists(1, false); + + // Try to settle - should revert due to inactive rail + vm.expectRevert("Rail does not exist"); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + // Amount should still be accumulated + (uint256 cdnAmount,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount, 100000, "Amount should still be accumulated"); + + // Reactivate rail and verify settlement works + mockPayments.setRailExists(1, true); + mockPayments.setLockupFixed(1, 100000); + + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 1, "Should settle after reactivation"); + + (uint256 cdnAmountAfter,,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmountAfter, 0, "Amount should be settled"); + } + + // Test multiple partial settlements without new usage + function test_MultiplePartialSettlementsWithoutNewUsage() public { + // Record initial usage + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, + _singleUint256Array(DATA_SET_ID_1), + _singleUint256Array(5000), // 500k CDN amount + _singleUint256Array(2500) // 500k cache miss amount + ); + + // First settlement - settles all accumulated amounts + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + + assertEq(mockFWSS.getSettlementsCount(), 2); + (uint256 cdnAmount1, uint256 cacheMissAmount1,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount1, 0, "CDN amount should be 0 after first settlement"); + assertEq(cacheMissAmount1, 0, "Cache miss amount should be 0 after first settlement"); + + // Simulate accumulated amounts from a partial settlement + // (In real scenario, this could happen if external contract limits settlement) + // We'll add more usage to simulate accumulation + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 2, + _singleUint256Array(DATA_SET_ID_1), + _singleUint256Array(3000), // 300k CDN amount + _singleUint256Array(1500) // 300k cache miss amount + ); + + // Second settlement - should settle new amounts + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 3); + + // Try to settle CDN again without new usage - should not create new settlement + uint256 settlementCountBefore = mockFWSS.getSettlementsCount(); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), settlementCountBefore, "Should not settle when no amount"); + + // Settle cache miss + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), 4); + + // Verify final state + (uint256 cdnAmount2, uint256 cacheMissAmount2,) = filBeam.dataSetUsage(DATA_SET_ID_1); + assertEq(cdnAmount2, 0, "CDN amount should be 0 after all settlements"); + assertEq(cacheMissAmount2, 0, "Cache miss amount should be 0 after all settlements"); + + // Try settling again - should not create new settlements + uint256 finalCount = mockFWSS.getSettlementsCount(); + filBeam.settleCDNPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(DATA_SET_ID_1)); + assertEq(mockFWSS.getSettlementsCount(), finalCount, "No new settlements when no amount"); + } + + // Test settlement with rail ID 0 (no rail configured) + function test_SettlementWithNoRailId() public { + // Create a data set with no rails (rail IDs = 0) + IFWSS.DataSetInfo memory dsInfo = IFWSS.DataSetInfo({ + pdpRailId: 0, + cacheMissRailId: 0, + cdnRailId: 0, + payer: user1, + payee: user2, + serviceProvider: address(0), + commissionBps: 0, + clientDataSetId: 0, + pdpEndEpoch: 0, + providerId: 0 + }); + uint256 dataSetId3 = 3; + mockFWSS.setDataSetInfo(dataSetId3, dsInfo); + + // Record usage + vm.prank(filBeamOperatorController); + filBeam.recordUsageRollups( + 1, _singleUint256Array(dataSetId3), _singleUint256Array(1000), _singleUint256Array(500) + ); + + // Try to settle - should not revert or settle + uint256 settlementCountBefore = mockFWSS.getSettlementsCount(); + filBeam.settleCDNPaymentRails(_singleUint256Array(dataSetId3)); + filBeam.settleCacheMissPaymentRails(_singleUint256Array(dataSetId3)); + assertEq(mockFWSS.getSettlementsCount(), settlementCountBefore, "Should not settle with rail ID 0"); + + // Amount should still be accumulated + (uint256 cdnAmount, uint256 cacheMissAmount,) = filBeam.dataSetUsage(dataSetId3); + assertEq(cdnAmount, 100000, "CDN amount should still be accumulated"); + assertEq(cacheMissAmount, 100000, "Cache miss amount should still be accumulated"); + } +}