diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de5c778..aeced7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Fixed - Fixed a lifecycle reserve underflow in `Rails.updateStorageRates()` that could cause `nextProvingPeriod` to fail ([#521](https://github.com/FilOzone/filecoin-services/pull/521)). - Fixed `abandonRails` to handle underfunded payers: when zeroing the lockup period is blocked by FilecoinPay, the lifecycle reserve is still released immediately but the PDP rail retains its default 30-day lockup period rather than finalizing at once ([#520](https://github.com/FilOzone/filecoin-services/pull/520)). +## [1.4.0] - Unreleased - Multi-token payments (axlUSDC) + network value-accrual fee + +This FWSS contract upgrade adds bridged USDC (axlUSDC, 6 decimals) as a second payment token and +introduces a network value-accrual fee (NVAF) on USDC rails: a small operator commission, accrued to +the FilecoinPay contract itself, sold for FIL through its recurring Dutch fee auction (`burnForFees`), +and burned to the burn actor (f099). USDFC data sets are unchanged and remain commission-free. + +### Added +- Per-data-set payment token selection via the payer-signed `paymentToken` metadata key (`"USDC"` or + `"USDFC"`; absent means USDFC). The choice is covered by the existing `CreateDataSet` EIP-712 + signature — no signature-format changes. +- `PriceListUSDC` (6-decimal price catalogue), exposed via + `FilecoinWarmStorageServiceStateView.getPriceListUSDC()`. Storage is priced from a `$5.00/TiB/month` + base (double the USDFC list) so providers net $5 per TiB-month on USDC rails; all other amounts keep + the USDFC-equivalent base. SP-bound amounts are grossed up by 1/(1 − 2%) so the SP nets the base + after the NVAF. The per-dataset fee is set at the 6-decimal per-epoch quantization floor + (`0.0864 USDC/month`). +- NVAF on USDC rails as the FilecoinPay per-rail operator commission (default 200 bps), locked into the + rails of each USDC data set at creation with the FilecoinPay contract itself as + `serviceFeeRecipient` — the commission joins FilecoinPay's own fee account and is sold and burned + through its existing `burnForFees` rolling Dutch auction; no new contract is involved. + Owner-stageable for future data sets via `setUSDCCommissionBps` (capped at 200 bps — the cap + equals the price gross-up so the SP-parity guarantee holds for every permitted setting), with + the `USDCCommissionBpsUpdated` event and `getUSDCCommissionBps()` view. +- `PaymentTokenSelected` event at data set creation, and + `FilecoinWarmStorageServiceStateView.getDataSetPaymentToken(dataSetId)` (pre-upgrade data sets + resolve to USDFC). + +### Changed +- `FilecoinWarmStorageService` constructor takes one new parameter after the USDFC token: the USDC + token address (zero address disables USDC data sets). +- `Rails.createRails` resolves per-token pricing and now also returns the creation fee and lifecycle + reserve target; `Rails` gained `priceListFor`, `resolvePaymentToken`, `oneTimeFees`, and + `replenishReserve` entry points. Token-independent size/time constants moved from `PriceListUSDFC` + to `PriceList` (re-exported for compatibility). +- Deployment scripts pass the new constructor argument; mainnet defaults the USDC token to axlUSDC + (`0xEB466342C4d449BC9f53A865D5Cb90586f405215`), calibration defaults to disabled. + +### Upgrade Notes +- Storage layout is append-only (two new slots); existing data sets are unaffected and continue to + resolve as USDFC for all fee paths. +- Payers funding USDC data sets must deposit USDC into FilecoinPay and approve FWSS as operator for + the USDC token (operator approvals are token-scoped). +- The NVAF applies only to rails created after the upgrade; FilecoinPay fixes a rail's commission at + creation. ## [1.3.0] - FWSS Upgrade diff --git a/service_contracts/abi/Errors.abi.json b/service_contracts/abi/Errors.abi.json index 5bf0bb76..608aa3ce 100644 --- a/service_contracts/abi/Errors.abi.json +++ b/service_contracts/abi/Errors.abi.json @@ -929,6 +929,28 @@ } ] }, + { + "type": "error", + "name": "UnknownRailToken", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedPaymentToken", + "inputs": [ + { + "name": "paymentToken", + "type": "string", + "internalType": "string" + } + ] + }, { "type": "error", "name": "UnsupportedSignatureV", diff --git a/service_contracts/abi/FilecoinWarmStorageService.abi.json b/service_contracts/abi/FilecoinWarmStorageService.abi.json index 48b19c04..3d924a71 100644 --- a/service_contracts/abi/FilecoinWarmStorageService.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageService.abi.json @@ -17,6 +17,11 @@ "type": "address", "internalType": "contract IERC20Metadata" }, + { + "name": "_usdc", + "type": "address", + "internalType": "contract IERC20Metadata" + }, { "name": "_filBeamBeneficiaryAddress", "type": "address", @@ -658,6 +663,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "setUSDCCommissionBps", + "inputs": [ + { + "name": "newCommissionBps", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setViewContract", @@ -851,6 +869,19 @@ "outputs": [], "stateMutability": "payable" }, + { + "type": "function", + "name": "usdcTokenAddress", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20Metadata" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "usdfcTokenAddress", @@ -1201,6 +1232,31 @@ ], "anonymous": false }, + { + "type": "event", + "name": "PaymentTokenSelected", + "inputs": [ + { + "name": "dataSetId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "contract IERC20" + }, + { + "name": "commissionBps", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "PieceAdded", @@ -1308,6 +1364,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "USDCCommissionBpsUpdated", + "inputs": [ + { + "name": "oldCommissionBps", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "newCommissionBps", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "UpgradeAnnounced", @@ -1486,6 +1561,27 @@ } ] }, + { + "type": "error", + "name": "CommissionExceedsMaximum", + "inputs": [ + { + "name": "commissionType", + "type": "uint8", + "internalType": "enum Errors.CommissionType" + }, + { + "name": "max", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "DataSetNotAbandoned", @@ -2088,6 +2184,17 @@ } ] }, + { + "type": "error", + "name": "UnknownRailToken", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, { "type": "error", "name": "ZeroAddress", diff --git a/service_contracts/abi/FilecoinWarmStorageServiceStateLibrary.abi.json b/service_contracts/abi/FilecoinWarmStorageServiceStateLibrary.abi.json index 987642a7..d7fc4920 100644 --- a/service_contracts/abi/FilecoinWarmStorageServiceStateLibrary.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageServiceStateLibrary.abi.json @@ -581,6 +581,30 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getDataSetPaymentToken", + "inputs": [ + { + "name": "service", + "type": "FilecoinWarmStorageService", + "internalType": "contract FilecoinWarmStorageService" + }, + { + "name": "dataSetId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "token", + "type": "IERC20", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getDataSetSizeInBytes", @@ -819,6 +843,147 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getPriceListUSDC", + "inputs": [ + { + "name": "service", + "type": "FilecoinWarmStorageService", + "internalType": "contract FilecoinWarmStorageService" + } + ], + "outputs": [ + { + "name": "list", + "type": "tuple", + "internalType": "struct PriceList", + "components": [ + { + "name": "token", + "type": "IERC20", + "internalType": "contract IERC20" + }, + { + "name": "rates", + "type": "tuple", + "internalType": "struct PriceListRates", + "components": [ + { + "name": "storagePerTibPerMonth", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "datasetFeePerMonth", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnEgressPerTib", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cacheMissEgressPerTib", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "fees", + "type": "tuple", + "internalType": "struct PriceListFees", + "components": [ + { + "name": "createDataSetFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "addPiecesBaseFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "addPiecesPerPieceFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "schedulePieceRemovalsFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "terminateFee", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "lockups", + "type": "tuple", + "internalType": "struct PriceListLockups", + "components": [ + { + "name": "lifecycleReserveTarget", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "replenishThreshold", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "defaultLockupPeriod", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnLockupAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cacheMissLockupAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnLockupPeriod", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getUSDCCommissionBps", + "inputs": [ + { + "name": "service", + "type": "FilecoinWarmStorageService", + "internalType": "contract FilecoinWarmStorageService" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "isProviderApproved", diff --git a/service_contracts/abi/FilecoinWarmStorageServiceStateView.abi.json b/service_contracts/abi/FilecoinWarmStorageServiceStateView.abi.json index ac0c6533..500643f5 100644 --- a/service_contracts/abi/FilecoinWarmStorageServiceStateView.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageServiceStateView.abi.json @@ -519,6 +519,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getDataSetPaymentToken", + "inputs": [ + { + "name": "dataSetId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "token", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getDataSetSizeInBytes", @@ -735,6 +754,135 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getPriceListUSDC", + "inputs": [], + "outputs": [ + { + "name": "list", + "type": "tuple", + "internalType": "struct PriceList", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "rates", + "type": "tuple", + "internalType": "struct PriceListRates", + "components": [ + { + "name": "storagePerTibPerMonth", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "datasetFeePerMonth", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnEgressPerTib", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cacheMissEgressPerTib", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "fees", + "type": "tuple", + "internalType": "struct PriceListFees", + "components": [ + { + "name": "createDataSetFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "addPiecesBaseFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "addPiecesPerPieceFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "schedulePieceRemovalsFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "terminateFee", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "lockups", + "type": "tuple", + "internalType": "struct PriceListLockups", + "components": [ + { + "name": "lifecycleReserveTarget", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "replenishThreshold", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "defaultLockupPeriod", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnLockupAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cacheMissLockupAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnLockupPeriod", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getUSDCCommissionBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "isProviderApproved", diff --git a/service_contracts/deployments.json b/service_contracts/deployments.json index b7f67de5..7948b07f 100644 --- a/service_contracts/deployments.json +++ b/service_contracts/deployments.json @@ -13,7 +13,6 @@ "SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS": "0xf55dDbf63F1b55c3F1D4FA7e339a68AB7b64A5eB", "SERVICE_PROVIDER_REGISTRY_IMPLEMENTATION_ADDRESS": "0x01293CaFdE24DE89fF26d1A19Bfc4E36CBF74F9B", "SIGNATURE_VERIFICATION_LIB_ADDRESS": "0xBbd3b82a9396156a997111D42c5356718522ed2D", - "RAILS_LIB_ADDRESS": "0x25649D27D556c64708D0BF9B20eC214eC991E223", "FWSS_PROXY_ADDRESS": "0x8408502033C418E1bbC97cE9ac48E5528F371A9f", "FWSS_IMPLEMENTATION_ADDRESS": "0xaF996097790c17D3C23Cc45A3035a29D293d1492", "FWSS_VIEW_ADDRESS": "0xAD28BBF18A72f728Ed816D07F5a1d7Ec40D68b5e", @@ -139,7 +138,6 @@ "SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS": "0x839e5c9988e4e9977d40708d0094103c0839Ac9D", "SERVICE_PROVIDER_REGISTRY_IMPLEMENTATION_ADDRESS": "0x0A2E79efFC7DB1D15912E4F6722F527F493F18Ef", "SIGNATURE_VERIFICATION_LIB_ADDRESS": "0x8408502033C418E1bbC97cE9ac48E5528F371A9f", - "RAILS_LIB_ADDRESS": "0x05BB7B022645a68fB9c97A7afaD1017EC5B252B2", "FWSS_PROXY_ADDRESS": "0x02925630df557F957f70E112bA06e50965417CA0", "FWSS_IMPLEMENTATION_ADDRESS": "0x9e4e6699d8F67dFc883d6b0A7344Bd56F7E80B46", "FWSS_VIEW_ADDRESS": "0xF4B446171b3677fD2B9b183a9fB76d517365700a", diff --git a/service_contracts/src/Errors.sol b/service_contracts/src/Errors.sol index 23972978..d328fd46 100644 --- a/service_contracts/src/Errors.sol +++ b/service_contracts/src/Errors.sol @@ -359,4 +359,15 @@ library Errors { /// @param requiredEpoch The first epoch at which abandonment is allowed /// @param currentBlock The current block number error DataSetNotAbandoned(uint256 dataSetId, uint256 requiredEpoch, uint256 currentBlock); + + /// @notice The `paymentToken` metadata value does not name a token this deployment supports + /// @param paymentToken The unsupported metadata value (supported: "USDFC", and "USDC" when configured) + error UnsupportedPaymentToken(string paymentToken); + + /// @notice A data set's stored rail token matches neither of this implementation's token + /// immutables. Guards against an upgrade changing `usdcTokenAddress` while data sets + /// denominated in the previous token exist — failing loudly instead of silently + /// pricing a 6-decimal token with the 18-decimal USDFC list. + /// @param token The stored rail token + error UnknownRailToken(address token); } diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index cd045d54..3a918d0a 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -21,20 +21,17 @@ import {Extsload} from "./Extsload.sol"; import { CACHE_MISS_EGRESS_PRICE_PER_TIB, - ADD_PIECES_BASE_FEE, - ADD_PIECES_PER_PIECE_FEE, - CREATE_DATA_SET_FEE, CDN_EGRESS_PRICE_PER_TIB, DATASET_FEE_PER_MONTH, - DEFAULT_LOCKUP_PERIOD, EPOCHS_PER_MONTH, - LIFECYCLE_RESERVE_TARGET, - SCHEDULE_PIECE_REMOVALS_FEE, SERVICE_COMMISSION_BPS, STORAGE_PRICE_PER_TIB_PER_MONTH, - TERMINATE_FEE, TOKEN_DECIMALS } from "./lib/PriceListUSDFC.sol"; +import { + MAX_USDC_SERVICE_COMMISSION_BPS, USDC_SERVICE_COMMISSION_BPS, USDC_TOKEN_DECIMALS +} from "./lib/PriceListUSDC.sol"; +import {DEFAULT_LOCKUP_PERIOD} from "./lib/PriceList.sol"; import {Rails} from "./lib/Rails.sol"; import {SignatureVerificationLib} from "./lib/SignatureVerificationLib.sol"; @@ -78,7 +75,7 @@ contract FilecoinWarmStorageService is EIP712Upgradeable { // Version tracking - string public constant VERSION = "1.3.0"; + string public constant VERSION = "1.4.0"; using Rails for FilecoinPayV1; @@ -129,6 +126,14 @@ contract FilecoinWarmStorageService is event ViewContractSet(address indexed viewContract); + /// @notice Emitted at data set creation with the rail token and the commission locked into + /// its rails. Commission on USDC rails is the network value-accrual fee (NVAF), + /// accrued to the FilecoinPay contract itself and burned via its fee auction. + event PaymentTokenSelected(uint256 indexed dataSetId, IERC20 token, uint256 commissionBps); + + /// @notice Emitted when the owner re-stages the NVAF for future USDC data sets. + event USDCCommissionBpsUpdated(uint256 oldCommissionBps, uint256 newCommissionBps); + // Events for provider management event ProviderApproved(uint256 indexed providerId); event ProviderUnapproved(uint256 indexed providerId); @@ -234,6 +239,8 @@ contract FilecoinWarmStorageService is address public immutable pdpVerifierAddress; address public immutable paymentsContractAddress; IERC20Metadata public immutable usdfcTokenAddress; + // Optional second rail token (bridged USDC, 6 decimals); zero address = USDC support disabled + IERC20Metadata public immutable usdcTokenAddress; address public immutable filBeamBeneficiaryAddress; ServiceProviderRegistry public immutable serviceProviderRegistry; SessionKeyRegistry public immutable sessionKeyRegistry; @@ -304,6 +311,15 @@ contract FilecoinWarmStorageService is // Piece IDs awaiting metadata cleanup; cleared each nextProvingPeriod call mapping(uint256 dataSetId => uint256[] pieceIds) internal scheduledPieceMetadataRemovals; + // Rail token chosen at data set creation. address(0) for data sets created before + // multi-token support; resolved as USDFC. + mapping(uint256 dataSetId => IERC20 token) private dataSetPaymentToken; + + // Network value-accrual fee (NVAF) in basis points, locked into the rails of *new* USDC + // data sets as the operator commission. Existing rails keep the commission they were + // created with (FilecoinPay forbids changing it). + uint256 private usdcCommissionBps; + event UpgradeAnnounced(PlannedUpgrade plannedUpgrade); // ========================================================================= @@ -327,6 +343,7 @@ contract FilecoinWarmStorageService is address _pdpVerifierAddress, address _paymentsContractAddress, IERC20Metadata _usdfc, + IERC20Metadata _usdc, address _filBeamBeneficiaryAddress, ServiceProviderRegistry _serviceProviderRegistry, SessionKeyRegistry _sessionKeyRegistry, @@ -344,6 +361,14 @@ contract FilecoinWarmStorageService is require(_usdfc != IERC20Metadata(address(0)), Errors.ZeroAddress(Errors.AddressField.USDFC)); usdfcTokenAddress = _usdfc; + // USDC is optional; deployments without a bridged USDC (or before enabling it) pass the + // zero address and only USDFC data sets can be created. + if (_usdc != IERC20Metadata(address(0))) { + // Verify token decimals from the USDC token contract + require(USDC_TOKEN_DECIMALS == _usdc.decimals()); + } + usdcTokenAddress = _usdc; + require(_filBeamBeneficiaryAddress != address(0), Errors.ZeroAddress(Errors.AddressField.FilBeamBeneficiary)); filBeamBeneficiaryAddress = _filBeamBeneficiaryAddress; @@ -404,6 +429,7 @@ contract FilecoinWarmStorageService is maxProvingPeriod = _maxProvingPeriod; challengeWindowSize = _challengeWindowSize; + usdcCommissionBps = USDC_SERVICE_COMMISSION_BPS; } function announceUpgradePlan(address nextImplementation, uint96 delayEpochs) external { @@ -465,9 +491,36 @@ contract FilecoinWarmStorageService is emit ViewContractSet(_viewContract); } + // Initialize the NVAF for deployments upgraded from a single-token version. Guarded so a + // commission staged via setUSDCCommissionBps survives later migrations (an intentional + // zero must be re-set after upgrading; see setUSDCCommissionBps). Emits the staging + // event so the re-default is always observable on-chain. + if (usdcCommissionBps == 0) { + emit USDCCommissionBpsUpdated(0, USDC_SERVICE_COMMISSION_BPS); + usdcCommissionBps = USDC_SERVICE_COMMISSION_BPS; + } + emit ContractUpgraded(VERSION, ERC1967Utils.getImplementation()); } + /** + * @notice Re-stages the network value-accrual fee (NVAF) applied to future USDC data sets. + * @dev Only affects rails created after the change; FilecoinPay fixes a rail's commission at + * creation. Capped at MAX_USDC_SERVICE_COMMISSION_BPS. Note a zero value is re-defaulted + * to USDC_SERVICE_COMMISSION_BPS by migrate() on the next upgrade. + * @param newCommissionBps The new commission in basis points + */ + function setUSDCCommissionBps(uint256 newCommissionBps) external onlyOwner { + require( + newCommissionBps <= MAX_USDC_SERVICE_COMMISSION_BPS, + Errors.CommissionExceedsMaximum( + Errors.CommissionType.Service, MAX_USDC_SERVICE_COMMISSION_BPS, newCommissionBps + ) + ); + emit USDCCommissionBpsUpdated(usdcCommissionBps, newCommissionBps); + usdcCommissionBps = newCommissionBps; + } + /** * @notice Sets the view contract address (one-time setup) * @dev Only callable by the contract owner. This is intended to be called once after deployment @@ -576,7 +629,6 @@ contract FilecoinWarmStorageService is info.payer = createData.payer; info.payee = payee; // Using payee address from registry info.serviceProvider = serviceProvider; // Set the service provider - info.commissionBps = SERVICE_COMMISSION_BPS; info.clientDataSetId = createData.clientDataSetId; info.providerId = providerId; @@ -611,7 +663,24 @@ contract FilecoinWarmStorageService is dataSetMetadata[dataSetId][key] = value; } - // Note: The payer must have pre-approved this contract to spend USDFC tokens before creating the data set + // Resolve the rail token from the signed metadata (absent means USDFC). USDC rails carry + // the network value-accrual fee as their commission, accrued to FilecoinPay itself so + // its fee auction sells the accrual for FIL and burns it. + (IERC20 railToken, uint256 commissionBps, address serviceFeeRecipient) = Rails.resolvePaymentToken( + createData.metadataKeys, + createData.metadataValues, + usdfcTokenAddress, + usdcTokenAddress, + usdcCommissionBps, + paymentsContractAddress, + address(this) + ); + dataSetPaymentToken[dataSetId] = railToken; + info.commissionBps = commissionBps; + emit PaymentTokenSelected(dataSetId, railToken, commissionBps); + + // Note: The payer must have pre-approved this contract to spend the rail token before + // creating the data set // Create the payment rails using the FilecoinPayV1 contract FilecoinPayV1 payments = FilecoinPayV1(paymentsContractAddress); @@ -619,14 +688,27 @@ contract FilecoinWarmStorageService is // Determine once whether CDN is enabled in metadata and reuse the result bool hasCDN = hasCDNMetadataKey(createData.metadataKeys); - (uint256 pdpRailId, uint256 cacheMissRailId, uint256 cdnRailId) = payments.createRails( - dataSetId, usdfcTokenAddress, createData.payer, payee, hasCDN ? filBeamBeneficiaryAddress : address(0) + ( + uint256 pdpRailId, + uint256 cacheMissRailId, + uint256 cdnRailId, + uint256 createDataSetFee, + uint256 lifecycleReserveTarget + ) = payments.createRails( + dataSetId, + railToken, + createData.payer, + payee, + hasCDN ? filBeamBeneficiaryAddress : address(0), + commissionBps, + serviceFeeRecipient, + railToken == usdcTokenAddress ); railToDataSet[pdpRailId] = dataSetId; info.pdpRailId = pdpRailId; - info.lifecycleReserveBalance = uint96(LIFECYCLE_RESERVE_TARGET); - info.pendingOneTimePayments = uint96(CREATE_DATA_SET_FEE); + info.lifecycleReserveBalance = uint96(lifecycleReserveTarget); + info.pendingOneTimePayments = uint96(createDataSetFee); if (hasCDN) { info.cacheMissRailId = cacheMissRailId; info.cdnRailId = cdnRailId; @@ -731,6 +813,7 @@ contract FilecoinWarmStorageService is processScheduledPieceMetadataRemovals(dataSetId); // Complete cleanup + delete dataSetPaymentToken[dataSetId]; delete dataSetInfo[dataSetId]; } @@ -799,8 +882,9 @@ contract FilecoinWarmStorageService is // Verify the signature verifyAddPiecesSignature(payer, info.clientDataSetId, pieceData, nonce, metadataKeys, metadataValues, signature); + (, uint256 addPiecesBaseFee, uint256 addPiecesPerPieceFee,,,) = Rails.oneTimeFees(_isUSDCDataSet(dataSetId)); uint96 pending = - info.pendingOneTimePayments + uint96(ADD_PIECES_BASE_FEE + pieceData.length * ADD_PIECES_PER_PIECE_FEE); + info.pendingOneTimePayments + uint96(addPiecesBaseFee + pieceData.length * addPiecesPerPieceFee); uint96 reserveBalance = info.lifecycleReserveBalance; // Validate lockup for the new data set size (fail-fast if client has insufficient funds) @@ -870,9 +954,11 @@ contract FilecoinWarmStorageService is // Verify the signature verifySchedulePieceRemovalsSignature(payer, info.clientDataSetId, pieceIds, signature); - uint96 newPending = info.pendingOneTimePayments + uint96(SCHEDULE_PIECE_REMOVALS_FEE); - info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).replenishReserveIfNeeded( - info.pdpRailId, info.pdpEndEpoch, info.lifecycleReserveBalance, newPending + bool usdcDataSet = _isUSDCDataSet(dataSetId); + (,,, uint256 schedulePieceRemovalsFee,,) = Rails.oneTimeFees(usdcDataSet); + uint96 newPending = info.pendingOneTimePayments + uint96(schedulePieceRemovalsFee); + info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).replenishReserve( + info.pdpRailId, info.pdpEndEpoch, info.lifecycleReserveBalance, newPending, usdcDataSet ); info.pendingOneTimePayments = newPending; @@ -1064,7 +1150,8 @@ contract FilecoinWarmStorageService is bytes memory signature = abi.decode(extraData, (bytes)); approver = _verifyTerminateServiceSignature(info.payer, dataSetId, signature); immediateTermination = true; - info.pendingOneTimePayments += uint96(TERMINATE_FEE); + (,,,, uint256 terminateFee,) = Rails.oneTimeFees(_isUSDCDataSet(dataSetId)); + info.pendingOneTimePayments += uint96(terminateFee); } else { require( msg.sender == info.payer || msg.sender == info.serviceProvider, @@ -1102,6 +1189,7 @@ contract FilecoinWarmStorageService is uint256 pdpRailId = info.pdpRailId; uint96 newBalance = info.lifecycleReserveBalance + uint96(amount); + // The lockup period is token-independent (identical across price lists) FilecoinPayV1(paymentsContractAddress).modifyRailLockup(pdpRailId, DEFAULT_LOCKUP_PERIOD, newBalance); info.lifecycleReserveBalance = newBalance; } @@ -1147,7 +1235,12 @@ contract FilecoinWarmStorageService is require(info.cacheMissRailId != 0 && info.cdnRailId != 0, Errors.InvalidDataSetId(dataSetId)); FilecoinPayV1(paymentsContractAddress).topUpCDNRails( - dataSetId, info.cacheMissRailId, info.cdnRailId, cacheMissAmountToAdd, cdnAmountToAdd + dataSetId, + info.cacheMissRailId, + info.cdnRailId, + cacheMissAmountToAdd, + cdnAmountToAdd, + _isUSDCDataSet(dataSetId) ); } @@ -1210,11 +1303,39 @@ contract FilecoinWarmStorageService is require(pdpRailId != 0, Errors.NoPDPPaymentRail(dataSetId)); info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).updateStorageRates( - dataSetId, pdpRailId, leafCount, pending, reserveBalance, info.pdpEndEpoch, immediateTermination + dataSetId, + pdpRailId, + leafCount, + pending, + reserveBalance, + info.pdpEndEpoch, + immediateTermination, + _isUSDCDataSet(dataSetId) ); info.pendingOneTimePayments = 0; } + /// @notice The rail token of a data set; data sets created before multi-token support + /// resolve to USDFC. + /// @dev Reverts for a stored token matching neither token immutable: an upgrade must not + /// change `usdcTokenAddress` while data sets denominated in the previous token exist, + /// and this guard turns that mistake into a loud revert instead of silently pricing a + /// 6-decimal token with the 18-decimal USDFC list. + function _paymentTokenOf(uint256 dataSetId) internal view returns (IERC20 token) { + token = dataSetPaymentToken[dataSetId]; + if (address(token) == address(0)) { + token = usdfcTokenAddress; + } else if (token != usdfcTokenAddress && token != usdcTokenAddress) { + revert Errors.UnknownRailToken(address(token)); + } + } + + /// @notice Whether a data set's rail token is the deployment's USDC instance, keying the + /// price list. Same guard semantics as {_paymentTokenOf}. + function _isUSDCDataSet(uint256 dataSetId) internal view returns (bool) { + return _paymentTokenOf(dataSetId) == usdcTokenAddress; + } + function processScheduledPieceMetadataRemovals(uint256 dataSetId) internal returns (bool hadRemovals) { uint256[] storage pieceIds = scheduledPieceMetadataRemovals[dataSetId]; uint256 len = pieceIds.length; diff --git a/service_contracts/src/FilecoinWarmStorageServiceStateView.sol b/service_contracts/src/FilecoinWarmStorageServiceStateView.sol index f6daaa43..530e42ae 100644 --- a/service_contracts/src/FilecoinWarmStorageServiceStateView.sol +++ b/service_contracts/src/FilecoinWarmStorageServiceStateView.sol @@ -9,6 +9,7 @@ import {FilecoinWarmStorageService} from "./FilecoinWarmStorageService.sol"; import {FilecoinWarmStorageServiceStateInternalLibrary} from "./lib/FilecoinWarmStorageServiceStateInternalLibrary.sol"; import {IPDPProvingSchedule} from "@pdp/IPDPProvingSchedule.sol"; import {PriceList} from "./lib/PriceList.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract FilecoinWarmStorageServiceStateView is IPDPProvingSchedule { using FilecoinWarmStorageServiceStateInternalLibrary for FilecoinWarmStorageService; @@ -103,6 +104,10 @@ contract FilecoinWarmStorageServiceStateView is IPDPProvingSchedule { return service.getDataSetMetadata(dataSetId, key); } + function getDataSetPaymentToken(uint256 dataSetId) external view returns (IERC20 token) { + return service.getDataSetPaymentToken(dataSetId); + } + function getDataSetSizeInBytes(uint256 leafCount) external pure returns (uint256) { return FilecoinWarmStorageServiceStateInternalLibrary.getDataSetSizeInBytes(leafCount); } @@ -140,6 +145,14 @@ contract FilecoinWarmStorageServiceStateView is IPDPProvingSchedule { return service.getPriceList(); } + function getPriceListUSDC() external view returns (PriceList memory list) { + return service.getPriceListUSDC(); + } + + function getUSDCCommissionBps() external view returns (uint256) { + return service.getUSDCCommissionBps(); + } + function isProviderApproved(uint256 providerId) external view returns (bool) { return service.isProviderApproved(providerId); } diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json index 89f94451..8c291d14 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json @@ -620,5 +620,37 @@ } } } + }, + { + "label": "dataSetPaymentToken", + "slot": "23", + "offset": 0, + "type": "mapping(uint256 => contract IERC20)", + "typeDetails": { + "label": "mapping(uint256 => contract IERC20)", + "encoding": "mapping", + "numberOfBytes": "32", + "key": { + "label": "uint256", + "encoding": "inplace", + "numberOfBytes": "32" + }, + "value": { + "label": "contract IERC20", + "encoding": "inplace", + "numberOfBytes": "20" + } + } + }, + { + "label": "usdcCommissionBps", + "slot": "24", + "offset": 0, + "type": "uint256", + "typeDetails": { + "label": "uint256", + "encoding": "inplace", + "numberOfBytes": "32" + } } ] diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol index b0ae7aa0..060969d2 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol @@ -28,3 +28,5 @@ bytes32 constant NEXT_UPGRADE_SLOT = bytes32(uint256(19)); bytes32 constant DEPRECATED_STORAGE_PRICE_PER_TIB_PER_MONTH_SLOT = bytes32(uint256(20)); bytes32 constant DEPRECATED_MINIMUM_STORAGE_RATE_PER_MONTH_SLOT = bytes32(uint256(21)); bytes32 constant SCHEDULED_PIECE_METADATA_REMOVALS_SLOT = bytes32(uint256(22)); +bytes32 constant DATA_SET_PAYMENT_TOKEN_SLOT = bytes32(uint256(23)); +bytes32 constant USDC_COMMISSION_BPS_SLOT = bytes32(uint256(24)); diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol index f65294ed..8f8a879d 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol @@ -16,6 +16,7 @@ import { STORAGE_PRICE_PER_TIB_PER_MONTH, priceList } from "./PriceListUSDFC.sol"; +import {priceListUSDC} from "./PriceListUSDC.sol"; import {PriceList} from "./PriceList.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./FilecoinWarmStorageServiceLayout.sol" as StorageLayout; @@ -654,4 +655,40 @@ library FilecoinWarmStorageServiceStateInternalLibrary { list = priceList(); list.token = IERC20(address(service.usdfcTokenAddress())); } + + /** + * @notice Get the USDC price catalogue for this FWSS deployment. + * @dev Same shape as `getPriceList()` but for USDC-denominated data sets (6-decimal amounts, + * grossed up for the network value-accrual fee). The `token` field is populated from + * the FWSS proxy's `usdcTokenAddress` immutable; the zero address means USDC support is + * disabled for this deployment. + */ + function getPriceListUSDC(FilecoinWarmStorageService service) internal view returns (PriceList memory list) { + list = priceListUSDC(); + list.token = IERC20(address(service.usdcTokenAddress())); + } + + /** + * @notice The rail token of a data set. + * @dev Data sets created before multi-token support resolve to USDFC. + */ + function getDataSetPaymentToken(FilecoinWarmStorageService service, uint256 dataSetId) + internal + view + returns (IERC20 token) + { + bytes32 slot = keccak256(abi.encode(dataSetId, StorageLayout.DATA_SET_PAYMENT_TOKEN_SLOT)); + token = IERC20(address(uint160(uint256(service.extsload(slot))))); + if (address(token) == address(0)) { + token = IERC20(address(service.usdfcTokenAddress())); + } + } + + /** + * @notice The network value-accrual fee (NVAF) in basis points locked into the rails of new + * USDC data sets. Existing rails keep the commission they were created with. + */ + function getUSDCCommissionBps(FilecoinWarmStorageService service) internal view returns (uint256) { + return uint256(service.extsload(StorageLayout.USDC_COMMISSION_BPS_SLOT)); + } } diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol index 47618bdb..3449f99b 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol @@ -12,6 +12,7 @@ import { STORAGE_PRICE_PER_TIB_PER_MONTH, priceList } from "./PriceListUSDFC.sol"; +import {priceListUSDC} from "./PriceListUSDC.sol"; import {PriceList} from "./PriceList.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./FilecoinWarmStorageServiceLayout.sol" as StorageLayout; @@ -646,4 +647,40 @@ library FilecoinWarmStorageServiceStateLibrary { list = priceList(); list.token = IERC20(address(service.usdfcTokenAddress())); } + + /** + * @notice Get the USDC price catalogue for this FWSS deployment. + * @dev Same shape as `getPriceList()` but for USDC-denominated data sets (6-decimal amounts, + * grossed up for the network value-accrual fee). The `token` field is populated from + * the FWSS proxy's `usdcTokenAddress` immutable; the zero address means USDC support is + * disabled for this deployment. + */ + function getPriceListUSDC(FilecoinWarmStorageService service) public view returns (PriceList memory list) { + list = priceListUSDC(); + list.token = IERC20(address(service.usdcTokenAddress())); + } + + /** + * @notice The rail token of a data set. + * @dev Data sets created before multi-token support resolve to USDFC. + */ + function getDataSetPaymentToken(FilecoinWarmStorageService service, uint256 dataSetId) + public + view + returns (IERC20 token) + { + bytes32 slot = keccak256(abi.encode(dataSetId, StorageLayout.DATA_SET_PAYMENT_TOKEN_SLOT)); + token = IERC20(address(uint160(uint256(service.extsload(slot))))); + if (address(token) == address(0)) { + token = IERC20(address(service.usdfcTokenAddress())); + } + } + + /** + * @notice The network value-accrual fee (NVAF) in basis points locked into the rails of new + * USDC data sets. Existing rails keep the commission they were created with. + */ + function getUSDCCommissionBps(FilecoinWarmStorageService service) public view returns (uint256) { + return uint256(service.extsload(StorageLayout.USDC_COMMISSION_BPS_SLOT)); + } } diff --git a/service_contracts/src/lib/PriceList.sol b/service_contracts/src/lib/PriceList.sol index 390d96f6..f5a16ab1 100644 --- a/service_contracts/src/lib/PriceList.sol +++ b/service_contracts/src/lib/PriceList.sol @@ -1,8 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.20; +import {Cids} from "@pdp/Cids.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +// Token-independent size and time constants shared by every per-token price list. +uint256 constant MIB_IN_BYTES = 1024 * 1024; // 1 MiB in bytes +uint256 constant GIB_IN_BYTES = MIB_IN_BYTES * 1024; // 1 GiB in bytes +uint256 constant TIB_IN_BYTES = GIB_IN_BYTES * 1024; // 1 TiB in bytes + +uint256 constant EPOCHS_PER_DAY = 2880; +uint256 constant EPOCHS_PER_MONTH = EPOCHS_PER_DAY * 30; +uint256 constant DEFAULT_LOCKUP_PERIOD = EPOCHS_PER_DAY * 30; +uint256 constant CDN_LOCKUP_PERIOD = EPOCHS_PER_DAY * 5; // shorter settle window for FilBeam + /// @notice Comprehensive price catalogue for an FWSS deployment. Returned by /// `FilecoinWarmStorageServiceStateView.getPriceList()`. All amounts are denominated in /// `token`'s smallest unit; rates are per-month (per-epoch values derived by dividing @@ -46,3 +57,30 @@ struct PriceListLockups { uint256 cacheMissLockupAmount; uint256 cdnLockupPeriod; } + +/** + * @notice Calculate a per-epoch rate from a price list based on total storage size + * @dev Adds the per-dataset fee to the size-proportional rate. Token-generic equivalent of + * `PriceListUSDFC.calculateStorageSizeBasedRatePerEpoch`; for the USDFC price list the two + * produce identical results. + * @param pl The price list whose rates apply (denominated in pl.token's smallest unit) + * @param totalBytes Total size of the stored data in bytes + * @return ratePerEpoch The calculated rate per epoch in the token's smallest unit + */ +function storageSizeBasedRatePerEpoch(PriceList memory pl, uint256 totalBytes) pure returns (uint256 ratePerEpoch) { + uint256 numerator = totalBytes * pl.rates.storagePerTibPerMonth; + uint256 denominator = TIB_IN_BYTES * EPOCHS_PER_MONTH; + + return numerator / denominator + pl.rates.datasetFeePerMonth / EPOCHS_PER_MONTH; +} + +/** + * @notice Calculate the storage rate per epoch for a leaf count (internal use) + * @param pl The price list whose rates apply + * @param leafCount the count of the 32b leaves in the FRC-0069 tree + * @return storageRatePerEpoch The storage rate per epoch + */ +function storageRatePerEpoch(PriceList memory pl, uint256 leafCount) pure returns (uint256) { + if (leafCount == 0) return 0; + return storageSizeBasedRatePerEpoch(pl, Cids.leafCountToRawSize(leafCount)); +} diff --git a/service_contracts/src/lib/PriceListUSDC.sol b/service_contracts/src/lib/PriceListUSDC.sol new file mode 100644 index 00000000..d6d35205 --- /dev/null +++ b/service_contracts/src/lib/PriceListUSDC.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + EPOCHS_PER_MONTH, + CDN_LOCKUP_PERIOD, + DEFAULT_LOCKUP_PERIOD, + PriceList, + PriceListFees, + PriceListLockups, + PriceListRates +} from "./PriceList.sol"; + +// Price list for USDC-denominated (bridged axlUSDC) data sets. +// +// Three deliberate differences from the USDFC list: +// +// 1. Storage price base. USDC storage is priced from a $5.00/TiB/month base — double the USDFC +// list's $2.50 — so providers net $5 per TiB-month on USDC rails. Every other amount keeps +// the USDFC-equivalent base. +// +// 2. Gross-up. USDC rails carry a network value-accrual fee (NVAF) as the rail's operator +// commission, accrued to the FilecoinPay contract itself — its fee auction sells the +// accrual for FIL and burns it, like the network fee. All SP-bound amounts below are +// grossed up by 1/(1 - 2%) — rounded up — so the SP nets the base amount after the +// commission; the customer bears the NVAF as a posted-price difference. (The 0.5% Filecoin +// Pay network fee applies identically on both tokens, so it does not enter the gross-up.) +// +// 3. Quantization floor. USDC has 6 decimals, and rails pay per epoch: any monthly amount below +// EPOCHS_PER_MONTH units ($0.0864) streams as zero. The per-dataset fee is therefore set at +// exactly 1 unit per epoch ($0.0864/month) — the smallest non-zero rate — rather than the +// USDFC list's $0.024/month. Size-proportional storage rates for very small data sets still +// truncate toward zero; the dataset fee floor keeps every active data set paying a non-zero +// stream. + +uint256 constant USDC_TOKEN_DECIMALS = 6; + +// axlUSDC has 6 decimals, so $1 = 10**6 +uint256 constant USDC_STORAGE_PRICE_PER_TIB_PER_MONTH = 5_102_041; // 5.00 / 0.98, ceil +uint256 constant USDC_DATASET_FEE_PER_MONTH = EPOCHS_PER_MONTH; // 1 unit/epoch quantization floor +uint256 constant USDC_DATASET_FEE_PER_EPOCH = USDC_DATASET_FEE_PER_MONTH / EPOCHS_PER_MONTH; + +uint256 constant USDC_CDN_EGRESS_PRICE_PER_TIB = 7_142_858; // 7 / 0.98 per TiB, ceil +uint256 constant USDC_CACHE_MISS_EGRESS_PRICE_PER_TIB = 7_142_858; // 7 / 0.98 per TiB, ceil + +uint256 constant USDC_DEFAULT_CDN_LOCKUP_AMOUNT = 714_286; // 0.7 / 0.98, ceil +uint256 constant USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT = 306_123; // 0.3 / 0.98, ceil + +// Default NVAF carried as operator commission on USDC rails; owner-adjustable up to the cap. +// The cap deliberately equals the gross-up (200 bps) so the SP-parity guarantee holds for every +// permitted setting: any commission at or below the cap leaves the SP netting at least the +// list's base amounts. Raising the NVAF beyond 2% requires a contract upgrade that also revises +// the grossed-up prices — keeping the two coupled by construction. +uint256 constant USDC_SERVICE_COMMISSION_BPS = 200; +uint256 constant MAX_USDC_SERVICE_COMMISSION_BPS = 200; + +// Operation fees (one-time, paid from the lifecycle reserve on the PDP rail), grossed up +uint256 constant USDC_CREATE_DATA_SET_FEE = 25_511; // $0.025 / 0.98 per dataset created +uint256 constant USDC_ADD_PIECES_BASE_FEE = 511; // $0.0005 / 0.98 base per addPieces call +uint256 constant USDC_ADD_PIECES_PER_PIECE_FEE = 307; // $0.0003 / 0.98 per piece added +uint256 constant USDC_SCHEDULE_PIECE_REMOVALS_FEE = 2_041; // $0.002 / 0.98 per schedulePieceRemovals call +uint256 constant USDC_TERMINATE_FEE = 1_143; // $0.00112 / 0.98 per user-initiated termination + +// Lifecycle reserve: payer-side buffer, not SP revenue — no gross-up +uint256 constant USDC_LIFECYCLE_RESERVE_TARGET = 100_000; // $0.10 +uint256 constant USDC_REPLENISH_THRESHOLD = 5_000; // $0.005 + +/** + * @notice Assemble the full PriceList from the USDC constants. + * @dev `token` returns as the zero address; the caller populates it with the deployment's + * USDC instance address (FWSS holds it as an immutable). + */ +function priceListUSDC() pure returns (PriceList memory) { + return PriceList({ + token: IERC20(address(0)), + rates: PriceListRates({ + storagePerTibPerMonth: USDC_STORAGE_PRICE_PER_TIB_PER_MONTH, + datasetFeePerMonth: USDC_DATASET_FEE_PER_MONTH, + cdnEgressPerTib: USDC_CDN_EGRESS_PRICE_PER_TIB, + cacheMissEgressPerTib: USDC_CACHE_MISS_EGRESS_PRICE_PER_TIB + }), + fees: PriceListFees({ + createDataSetFee: USDC_CREATE_DATA_SET_FEE, + addPiecesBaseFee: USDC_ADD_PIECES_BASE_FEE, + addPiecesPerPieceFee: USDC_ADD_PIECES_PER_PIECE_FEE, + schedulePieceRemovalsFee: USDC_SCHEDULE_PIECE_REMOVALS_FEE, + terminateFee: USDC_TERMINATE_FEE + }), + lockups: PriceListLockups({ + lifecycleReserveTarget: USDC_LIFECYCLE_RESERVE_TARGET, + replenishThreshold: USDC_REPLENISH_THRESHOLD, + defaultLockupPeriod: DEFAULT_LOCKUP_PERIOD, + cdnLockupAmount: USDC_DEFAULT_CDN_LOCKUP_AMOUNT, + cacheMissLockupAmount: USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, + cdnLockupPeriod: CDN_LOCKUP_PERIOD + }) + }); +} diff --git a/service_contracts/src/lib/PriceListUSDFC.sol b/service_contracts/src/lib/PriceListUSDFC.sol index a854ad41..5f8544e3 100644 --- a/service_contracts/src/lib/PriceListUSDFC.sol +++ b/service_contracts/src/lib/PriceListUSDFC.sol @@ -3,17 +3,18 @@ pragma solidity ^0.8.20; import {Cids} from "@pdp/Cids.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {PriceList, PriceListRates, PriceListFees, PriceListLockups} from "./PriceList.sol"; - -uint256 constant MIB_IN_BYTES = 1024 * 1024; // 1 MiB in bytes -uint256 constant GIB_IN_BYTES = MIB_IN_BYTES * 1024; // 1 GiB in bytes -uint256 constant TIB_IN_BYTES = GIB_IN_BYTES * 1024; // 1 TiB in bytes +import { + CDN_LOCKUP_PERIOD, + DEFAULT_LOCKUP_PERIOD, + EPOCHS_PER_MONTH, + PriceList, + PriceListFees, + PriceListLockups, + PriceListRates, + TIB_IN_BYTES +} from "./PriceList.sol"; uint256 constant TOKEN_DECIMALS = 18; -uint256 constant EPOCHS_PER_DAY = 2880; -uint256 constant DEFAULT_LOCKUP_PERIOD = EPOCHS_PER_DAY * 30; -uint256 constant CDN_LOCKUP_PERIOD = EPOCHS_PER_DAY * 5; // shorter settle window for FilBeam -uint256 constant EPOCHS_PER_MONTH = EPOCHS_PER_DAY * 30; // USDFC has 18 decimals, so $1 = 10**18 (a.k.a. ether) uint256 constant STORAGE_PRICE_PER_TIB_PER_MONTH = (5 * 10 ** TOKEN_DECIMALS) / 2; // 2.5 USDFC diff --git a/service_contracts/src/lib/Rails.sol b/service_contracts/src/lib/Rails.sol index 787126d4..d7fca697 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -4,19 +4,9 @@ pragma solidity ^0.8.20; import {Errors} from "../Errors.sol"; import {FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { - CDN_LOCKUP_PERIOD, - DATASET_FEE_PER_EPOCH, - DATASET_FEE_PER_MONTH, - DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, - DEFAULT_CDN_LOCKUP_AMOUNT, - DEFAULT_LOCKUP_PERIOD, - EPOCHS_PER_MONTH, - LIFECYCLE_RESERVE_TARGET, - REPLENISH_THRESHOLD, - SERVICE_COMMISSION_BPS, - calculateStorageRate -} from "./PriceListUSDFC.sol"; +import {DEFAULT_LOCKUP_PERIOD, EPOCHS_PER_MONTH, PriceList, storageRatePerEpoch} from "./PriceList.sol"; +import {SERVICE_COMMISSION_BPS, priceList as priceListUSDFC} from "./PriceListUSDFC.sol"; +import {priceListUSDC} from "./PriceListUSDC.sol"; event CDNPaymentRailsToppedUp( uint256 indexed dataSetId, @@ -35,34 +25,115 @@ event DataSetAbandoned(uint256 indexed dataSetId, uint256 pdpRailId, uint256 cac event RailRateUpdated(uint256 indexed dataSetId, uint256 railId, uint256 newRate); library Rails { + /// @notice Returns the price list for the caller-resolved token choice. + /// @dev The caller decides whether the rail token is the deployment's USDC instance (an + /// immutable compare in the main contract); the library only keys the list off that + /// decision. Lives here (external library) to keep the per-token price constants out + /// of the main contract's code size. `pl.token` is left as the zero address; callers + /// that expose the struct populate it themselves. + function priceListFor(bool usdc) internal pure returns (PriceList memory pl) { + return usdc ? priceListUSDC() : priceListUSDFC(); + } + + /// @notice Resolves the rail token, commission, and commission recipient from data set + /// creation metadata. An absent `paymentToken` key (or explicit "USDFC") selects + /// USDFC with the base commission; "USDC" selects the configured USDC instance + /// carrying the network value-accrual fee, accrued to FilecoinPay itself so its + /// fee auction sells and burns it. Any other value reverts. + /// @param metadataKeys The data set creation metadata keys (payer-signed) + /// @param metadataValues The data set creation metadata values (payer-signed) + /// @param usdfc The deployment's USDFC instance + /// @param usdc The deployment's USDC instance (zero address when disabled) + /// @param usdcCommissionBps The NVAF to lock into USDC rails + /// @param usdcFeeRecipient Receives USDC-rail commission: the FilecoinPay contract itself, + /// whose fee auction sells the accrual for FIL and burns it (same mechanism as the + /// network fee) + /// @param usdfcFeeRecipient Commission recipient on USDFC rails (unused while base + /// commission is zero) + function resolvePaymentToken( + string[] memory metadataKeys, + string[] memory metadataValues, + IERC20 usdfc, + IERC20 usdc, + uint256 usdcCommissionBps, + address usdcFeeRecipient, + address usdfcFeeRecipient + ) public pure returns (IERC20 token, uint256 commissionBps, address serviceFeeRecipient) { + for (uint256 i = 0; i < metadataKeys.length; i++) { + bytes memory keyBytes = bytes(metadataKeys[i]); + if (keyBytes.length == 12 && bytes12(keyBytes) == "paymentToken") { + bytes memory valueBytes = bytes(metadataValues[i]); + if (valueBytes.length == 4 && bytes4(valueBytes) == "USDC") { + if (address(usdc) == address(0)) { + revert Errors.UnsupportedPaymentToken(metadataValues[i]); + } + return (usdc, usdcCommissionBps, usdcFeeRecipient); + } + if (valueBytes.length != 5 || bytes5(valueBytes) != "USDFC") { + revert Errors.UnsupportedPaymentToken(metadataValues[i]); + } + break; // explicit "USDFC" selects the default + } + } + return (usdfc, SERVICE_COMMISSION_BPS, usdfcFeeRecipient); + } + + /// @notice The one-time fees and lifecycle reserve target for the rail token, as flat words. + /// @dev Leaner for the main contract to decode than the full PriceList struct (code size). + /// @param usdc Whether the data set's rail token is the deployment's USDC instance + function oneTimeFees(bool usdc) + public + pure + returns ( + uint256 createDataSetFee, + uint256 addPiecesBaseFee, + uint256 addPiecesPerPieceFee, + uint256 schedulePieceRemovalsFee, + uint256 terminateFee, + uint256 lifecycleReserveTarget + ) + { + PriceList memory pl = priceListFor(usdc); + return ( + pl.fees.createDataSetFee, + pl.fees.addPiecesBaseFee, + pl.fees.addPiecesPerPieceFee, + pl.fees.schedulePieceRemovalsFee, + pl.fees.terminateFee, + pl.lockups.lifecycleReserveTarget + ); + } + /// @notice Validates the payer is set up to add pieces, not just create the dataset. /// @dev The lifecycle reserve is consumed at creation; the per-dataset fee headroom is only /// consumed once pieces are added. Empty datasets leave it as approved headroom. /// Required up front intentionally: creation assumes pieces will follow. /// @param payments The FilecoinPayV1 contract instance - /// @param usdfcTokenAddress The USDFC token used for deposits and operator approvals + /// @param token The rail token used for deposits and operator approvals /// @param payer The address of the payer /// @param includeCDN Whether to include fixed CDN/cache-miss lockups in the requirement checks + /// @param pl The price list applying to `token` function validatePayerOperatorApprovalAndFunds( FilecoinPayV1 payments, - IERC20 usdfcTokenAddress, + IERC20 token, address payer, - bool includeCDN + bool includeCDN, + PriceList memory pl ) internal view { // Required capacity: lifecycle reserve plus per-dataset fee lockup at the default period. // Multiply-first preserves the exact monthly value for cleaner error messages; slightly // more conservative than the actual rail lockup (truncated per-epoch), within 0.0001% // and always in the user's favor. - uint256 requiredLockup = - (DATASET_FEE_PER_MONTH * DEFAULT_LOCKUP_PERIOD) / EPOCHS_PER_MONTH + LIFECYCLE_RESERVE_TARGET; + uint256 requiredLockup = (pl.rates.datasetFeePerMonth * pl.lockups.defaultLockupPeriod) / EPOCHS_PER_MONTH + + pl.lockups.lifecycleReserveTarget; // If CDN is enabled, include the fixed cache-miss and CDN lockup amounts if (includeCDN) { - requiredLockup += DEFAULT_CACHE_MISS_LOCKUP_AMOUNT + DEFAULT_CDN_LOCKUP_AMOUNT; + requiredLockup += pl.lockups.cacheMissLockupAmount + pl.lockups.cdnLockupAmount; } // Check that payer has sufficient available funds - (,, uint256 availableFunds,) = payments.getAccountInfoIfSettled(usdfcTokenAddress, payer); + (,, uint256 availableFunds,) = payments.getAccountInfoIfSettled(token, payer); require(availableFunds >= requiredLockup, Errors.InsufficientLockupFunds(payer, requiredLockup, availableFunds)); // Check operator approval settings @@ -73,7 +144,7 @@ library Rails { uint256 rateUsage, uint256 lockupUsage, uint256 maxLockupPeriod - ) = payments.operatorApprovals(usdfcTokenAddress, payer, address(this)); + ) = payments.operatorApprovals(token, payer, address(this)); // Verify operator is approved require(isApproved, Errors.OperatorNotApproved(payer, address(this))); @@ -81,9 +152,10 @@ library Rails { // Rate-allowance headroom for the per-dataset fee rate: the floor of any non-empty // dataset's rate (size-proportional component sits on top). Empty datasets never consume // it; required up front for the dataset to be eligible to receive pieces. + uint256 datasetFeePerEpoch = pl.rates.datasetFeePerMonth / EPOCHS_PER_MONTH; require( - rateAllowance >= rateUsage + DATASET_FEE_PER_EPOCH, - Errors.InsufficientRateAllowance(payer, address(this), rateAllowance, rateUsage, DATASET_FEE_PER_EPOCH) + rateAllowance >= rateUsage + datasetFeePerEpoch, + Errors.InsufficientRateAllowance(payer, address(this), rateAllowance, rateUsage, datasetFeePerEpoch) ); // Verify lockup allowance is sufficient @@ -94,66 +166,81 @@ library Rails { // Verify max lockup period is sufficient require( - maxLockupPeriod >= DEFAULT_LOCKUP_PERIOD, - Errors.InsufficientMaxLockupPeriod(payer, address(this), maxLockupPeriod, DEFAULT_LOCKUP_PERIOD) + maxLockupPeriod >= pl.lockups.defaultLockupPeriod, + Errors.InsufficientMaxLockupPeriod(payer, address(this), maxLockupPeriod, pl.lockups.defaultLockupPeriod) ); } function createRails( FilecoinPayV1 payments, uint256 dataSetId, - IERC20 usdfcTokenAddress, + IERC20 token, address payer, address payee, - address filBeamBeneficiaryAddress - ) public returns (uint256 pdpRailId, uint256 cacheMissRailId, uint256 cdnRailId) { + address filBeamBeneficiaryAddress, + uint256 commissionBps, + address serviceFeeRecipient, + bool usdc + ) + public + returns ( + uint256 pdpRailId, + uint256 cacheMissRailId, + uint256 cdnRailId, + uint256 createDataSetFee, + uint256 lifecycleReserveTarget + ) + { + PriceList memory pl = priceListFor(usdc); + createDataSetFee = pl.fees.createDataSetFee; + lifecycleReserveTarget = pl.lockups.lifecycleReserveTarget; bool hasCDN = filBeamBeneficiaryAddress != address(0); // Validate payer has sufficient funds and operator approvals to cover the required lockup // If CDN is enabled, validation must account for the additional fixed lockup amounts - validatePayerOperatorApprovalAndFunds(payments, usdfcTokenAddress, payer, hasCDN); + validatePayerOperatorApprovalAndFunds(payments, token, payer, hasCDN, pl); pdpRailId = payments.createRail( - usdfcTokenAddress, // token address + token, // token address payer, // from (payer) payee, // payee address from registry address(this), // this contract acts as the validator - SERVICE_COMMISSION_BPS, // commission rate based on CDN usage - address(this) + commissionBps, // commission carries the network value-accrual fee on USDC rails + serviceFeeRecipient ); // Set lockup period and seed the lifecycle reserve - payments.modifyRailLockup(pdpRailId, DEFAULT_LOCKUP_PERIOD, LIFECYCLE_RESERVE_TARGET); + payments.modifyRailLockup(pdpRailId, pl.lockups.defaultLockupPeriod, pl.lockups.lifecycleReserveTarget); cacheMissRailId = 0; cdnRailId = 0; if (hasCDN) { cacheMissRailId = payments.createRail( - usdfcTokenAddress, // token address + token, // token address payer, // from (payer) payee, // payee address from registry address(0), // no validator - 0, // no service commission - address(this) // controller + commissionBps, // same per-token commission as the PDP rail + serviceFeeRecipient ); - payments.modifyRailLockup(cacheMissRailId, CDN_LOCKUP_PERIOD, DEFAULT_CACHE_MISS_LOCKUP_AMOUNT); + payments.modifyRailLockup(cacheMissRailId, pl.lockups.cdnLockupPeriod, pl.lockups.cacheMissLockupAmount); cdnRailId = payments.createRail( - usdfcTokenAddress, // token address + token, // token address payer, // from (payer) filBeamBeneficiaryAddress, // to FilBeam beneficiary address(0), // no validator - 0, // no service commission - address(this) // controller + commissionBps, // same per-token commission as the PDP rail + serviceFeeRecipient ); - payments.modifyRailLockup(cdnRailId, CDN_LOCKUP_PERIOD, DEFAULT_CDN_LOCKUP_AMOUNT); + payments.modifyRailLockup(cdnRailId, pl.lockups.cdnLockupPeriod, pl.lockups.cdnLockupAmount); emit CDNPaymentRailsToppedUp( dataSetId, - DEFAULT_CDN_LOCKUP_AMOUNT, - DEFAULT_CDN_LOCKUP_AMOUNT, - DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, - DEFAULT_CACHE_MISS_LOCKUP_AMOUNT + pl.lockups.cdnLockupAmount, + pl.lockups.cdnLockupAmount, + pl.lockups.cacheMissLockupAmount, + pl.lockups.cacheMissLockupAmount ); } } @@ -226,8 +313,10 @@ library Rails { uint256 cacheMissRailId, uint256 cdnRailId, uint256 cacheMissAmountToAdd, - uint256 cdnAmountToAdd + uint256 cdnAmountToAdd, + bool usdc ) public { + PriceList memory pl = priceListFor(usdc); // Both rails must be active for any top-up operation FilecoinPayV1.RailView memory cdnRail = payments.getRail(cdnRailId); FilecoinPayV1.RailView memory cacheMissRail = payments.getRail(cacheMissRailId); @@ -245,8 +334,8 @@ library Rails { uint256 totalCacheMissLockup = cacheMissRail.lockupFixed + cacheMissAmountToAdd; // Only modify rails if amounts are being added - payments.modifyRailLockup(cdnRailId, CDN_LOCKUP_PERIOD, totalCdnLockup); - payments.modifyRailLockup(cacheMissRailId, CDN_LOCKUP_PERIOD, totalCacheMissLockup); + payments.modifyRailLockup(cdnRailId, pl.lockups.cdnLockupPeriod, totalCdnLockup); + payments.modifyRailLockup(cacheMissRailId, pl.lockups.cdnLockupPeriod, totalCacheMissLockup); emit CDNPaymentRailsToppedUp( dataSetId, cdnAmountToAdd, totalCdnLockup, cacheMissAmountToAdd, totalCacheMissLockup ); @@ -268,24 +357,39 @@ library Rails { } } - // Replenishes the rail's fixed lockup when the reserve would drop below REPLENISH_THRESHOLD - // after paying pending. Returns the new lockupFixed value (mirrors lifecycleReserveBalance). - // Skipped for terminated rails (pdpEndEpoch != 0): modifyRailLockup forbids increases there. + // Replenishes the rail's fixed lockup when the reserve would drop below the replenish + // threshold after paying pending. Returns the new lockupFixed value (mirrors + // lifecycleReserveBalance). Skipped for terminated rails (pdpEndEpoch != 0): + // modifyRailLockup forbids increases there. function replenishReserveIfNeeded( FilecoinPayV1 payments, uint256 pdpRailId, uint256 pdpEndEpoch, uint96 reserveBalance, - uint96 pending + uint96 pending, + PriceList memory pl ) internal returns (uint96) { - if (pdpEndEpoch == 0 && reserveBalance < pending + uint96(REPLENISH_THRESHOLD)) { - uint96 newLockup = uint96(LIFECYCLE_RESERVE_TARGET) + pending; - payments.modifyRailLockup(pdpRailId, DEFAULT_LOCKUP_PERIOD, newLockup); + if (pdpEndEpoch == 0 && reserveBalance < pending + uint96(pl.lockups.replenishThreshold)) { + uint96 newLockup = uint96(pl.lockups.lifecycleReserveTarget) + pending; + payments.modifyRailLockup(pdpRailId, pl.lockups.defaultLockupPeriod, newLockup); return newLockup; } return reserveBalance; } + /// @notice Public entry point for {replenishReserveIfNeeded}, resolving the price list from + /// the rail token choice. The internal variant stays inlined into in-library callers. + function replenishReserve( + FilecoinPayV1 payments, + uint256 pdpRailId, + uint256 pdpEndEpoch, + uint96 reserveBalance, + uint96 pending, + bool usdc + ) public returns (uint96) { + return replenishReserveIfNeeded(payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, priceListFor(usdc)); + } + function updateStorageRates( FilecoinPayV1 payments, uint256 dataSetId, @@ -294,15 +398,17 @@ library Rails { uint96 pending, uint96 reserveBalance, uint256 pdpEndEpoch, - bool immediateTermination + bool immediateTermination, + bool usdc ) public returns (uint96 newReserveBalance) { - uint256 newStorageRatePerEpoch = calculateStorageRate(leafCount); + PriceList memory pl = priceListFor(usdc); + uint256 newStorageRatePerEpoch = storageRatePerEpoch(pl, leafCount); if (immediateTermination) { // No try/catch: immediateTermination implies the payer consented and is solvent. payments.modifyRailLockup(pdpRailId, 0, pending); newReserveBalance = 0; } else { - uint96 replenished = replenishReserveIfNeeded(payments, pdpRailId, pdpEndEpoch, reserveBalance, pending); + uint96 replenished = replenishReserveIfNeeded(payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, pl); if (replenished < pending) { pending = replenished; } diff --git a/service_contracts/test/Abandonment.t.sol b/service_contracts/test/Abandonment.t.sol index 915baa4a..9df943ed 100644 --- a/service_contracts/test/Abandonment.t.sol +++ b/service_contracts/test/Abandonment.t.sol @@ -18,12 +18,8 @@ import { } from "../src/lib/FilecoinWarmStorageServiceLayout.sol"; import {FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; import {Errors as PaymentsErrors} from "@fws-payments/Errors.sol"; -import { - calculateStorageRate, - DEFAULT_LOCKUP_PERIOD, - EPOCHS_PER_DAY, - LIFECYCLE_RESERVE_TARGET -} from "../src/lib/PriceListUSDFC.sol"; +import {calculateStorageRate, DEFAULT_LOCKUP_PERIOD, LIFECYCLE_RESERVE_TARGET} from "../src/lib/PriceListUSDFC.sol"; +import {EPOCHS_PER_DAY} from "../src/lib/PriceList.sol"; import {Errors} from "../src/Errors.sol"; import {MockERC20} from "./mocks/SharedMocks.sol"; import {CDNServiceTerminated, DataSetAbandoned} from "../src/lib/Rails.sol"; @@ -102,6 +98,7 @@ contract AbandonmentTest is MockFVMTest { address(pdpVerifier), address(payments), usdfc, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/FilecoinWarmStorageService.t.sol b/service_contracts/test/FilecoinWarmStorageService.t.sol index b89fc3f3..1f6e3821 100644 --- a/service_contracts/test/FilecoinWarmStorageService.t.sol +++ b/service_contracts/test/FilecoinWarmStorageService.t.sol @@ -222,6 +222,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -307,6 +308,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -340,6 +342,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -363,6 +366,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -386,6 +390,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -419,6 +424,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -461,6 +467,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -500,6 +507,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -570,6 +578,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -5500,6 +5509,7 @@ contract FilecoinWarmStorageServiceUpgradeTest is Test { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -5637,7 +5647,7 @@ contract FilecoinWarmStorageServiceUpgradeTest is Test { if (logs[i].topics[0] == expectedTopic) { // Decode and verify the event data (string memory version, address implementation) = abi.decode(logs[i].data, (string, address)); - assertEq(version, "1.3.0", "Version should be 1.3.0"); + assertEq(version, "1.4.0", "Version should be 1.4.0"); assertTrue(implementation != address(0), "Implementation address should not be zero"); foundEvent = true; break; diff --git a/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol b/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol index 6d3cf1e7..1c9231dc 100644 --- a/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol +++ b/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol @@ -97,6 +97,7 @@ contract FilecoinWarmStorageServiceOwnerTest is MockFVMTest { address(pdpVerifier), address(payments), usdfcToken, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, providerRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/MultiTokenValueAccrual.t.sol b/service_contracts/test/MultiTokenValueAccrual.t.sol new file mode 100644 index 00000000..ac49bfe8 --- /dev/null +++ b/service_contracts/test/MultiTokenValueAccrual.t.sol @@ -0,0 +1,768 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {MockFVMTest} from "@fvm-solidity/mocks/MockFVMTest.sol"; +import {BURN_ADDRESS} from "@fvm-solidity/FVMActors.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Cids} from "@pdp/Cids.sol"; +import {MyERC1967Proxy} from "@pdp/ERC1967Proxy.sol"; +import {SessionKeyRegistry} from "@session-key-registry/SessionKeyRegistry.sol"; +import {FIRST_AUCTION_START_PRICE, FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; + +import {FilecoinWarmStorageService} from "../src/FilecoinWarmStorageService.sol"; +import {FilecoinWarmStorageServiceStateView} from "../src/FilecoinWarmStorageServiceStateView.sol"; +import {Errors} from "../src/Errors.sol"; +import {ServiceProviderRegistry} from "../src/ServiceProviderRegistry.sol"; +import {ServiceProviderRegistryStorage} from "../src/ServiceProviderRegistryStorage.sol"; +import { + ADD_PIECES_BASE_FEE, + ADD_PIECES_PER_PIECE_FEE, + CREATE_DATA_SET_FEE, + LIFECYCLE_RESERVE_TARGET, + calculateStorageSizeBasedRatePerEpoch +} from "../src/lib/PriceListUSDFC.sol"; +import { + MAX_USDC_SERVICE_COMMISSION_BPS, + USDC_ADD_PIECES_BASE_FEE, + USDC_ADD_PIECES_PER_PIECE_FEE, + USDC_CREATE_DATA_SET_FEE, + USDC_DATASET_FEE_PER_MONTH, + USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, + USDC_DEFAULT_CDN_LOCKUP_AMOUNT, + USDC_LIFECYCLE_RESERVE_TARGET, + USDC_SCHEDULE_PIECE_REMOVALS_FEE, + USDC_SERVICE_COMMISSION_BPS, + USDC_STORAGE_PRICE_PER_TIB_PER_MONTH, + USDC_TERMINATE_FEE +} from "../src/lib/PriceListUSDC.sol"; +import {EPOCHS_PER_MONTH, PriceList, storageSizeBasedRatePerEpoch} from "../src/lib/PriceList.sol"; +import {MockERC20, MockPDPVerifier, MockUSDC} from "./mocks/SharedMocks.sol"; +import {PDPOffering} from "./PDPOffering.sol"; + +contract MultiTokenValueAccrualTest is MockFVMTest { + using SafeERC20 for MockERC20; + using SafeERC20 for MockUSDC; + using PDPOffering for PDPOffering.Schema; + + bytes constant FAKE_SIGNATURE = abi.encodePacked( + bytes32(0xc0ffee7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef), // r + bytes32(0x9999997890abcdef1234567890abcdef1234567890abcdef1234567890abcdef), // s + uint8(27) // v + ); + + uint256 constant COMMISSION_MAX_BPS = 10000; + + FilecoinWarmStorageService public service; + FilecoinWarmStorageServiceStateView public viewContract; + MockPDPVerifier public mockPDPVerifier; + FilecoinPayV1 public payments; + MockERC20 public mockUSDFC; + MockUSDC public mockUSDC; + ServiceProviderRegistry public serviceProviderRegistry; + SessionKeyRegistry public sessionKeyRegistry = new SessionKeyRegistry(); + + address public client; + address public serviceProvider; + address public filBeamController; + address public filBeamBeneficiary; + + uint256 nextClientDataSetId = 0; + uint256 nextNonce = 1000; + + function setUp() public override { + super.setUp(); // etch FVM precompile mocks (burn actor support) + + client = address(0xf1); + serviceProvider = address(0xf2); + filBeamController = address(0xf3); + filBeamBeneficiary = address(0xf4); + + vm.deal(client, 100 ether); + vm.deal(serviceProvider, 100 ether); + + mockUSDFC = new MockERC20(); + mockUSDC = new MockUSDC(); + mockPDPVerifier = new MockPDPVerifier(); + + ServiceProviderRegistry registryImpl = new ServiceProviderRegistry(1); + bytes memory registryInitData = abi.encodeWithSelector(ServiceProviderRegistry.initialize.selector); + MyERC1967Proxy registryProxy = new MyERC1967Proxy(address(registryImpl), registryInitData); + serviceProviderRegistry = ServiceProviderRegistry(address(registryProxy)); + + PDPOffering.Schema memory pdpData = PDPOffering.Schema({ + serviceURL: "https://provider.com", + minPieceSizeInBytes: 1024, + maxPieceSizeInBytes: 1024 * 1024, + ipniPiece: true, + ipniIpfs: false, + storagePricePerTibPerDay: 1 ether, + minProvingPeriodInEpochs: 2880, + location: "US-Central", + paymentTokenAddress: IERC20(address(0)) + }); + (string[] memory keys, bytes[] memory values) = pdpData.toCapabilities(); + + vm.prank(serviceProvider); + serviceProviderRegistry.registerProvider{value: 5 ether}( + serviceProvider, + "Service Provider", + "Service Provider Description", + ServiceProviderRegistryStorage.ProductType.PDP, + keys, + values + ); + + payments = new FilecoinPayV1(); + + FilecoinWarmStorageService serviceImpl = new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + mockUSDC, + filBeamBeneficiary, + serviceProviderRegistry, + sessionKeyRegistry, + 4 + ); + bytes memory initializeData = abi.encodeWithSelector( + FilecoinWarmStorageService.initialize.selector, + uint64(2880), + uint256(60), + filBeamController, + "Filecoin Warm Storage Service", + "Multi-token warm storage with value accrual" + ); + MyERC1967Proxy serviceProxy = new MyERC1967Proxy(address(serviceImpl), initializeData); + service = FilecoinWarmStorageService(address(serviceProxy)); + service.addApprovedProvider(1); + + viewContract = new FilecoinWarmStorageServiceStateView(service); + service.setViewContract(address(viewContract)); + + // Fund the client with both tokens and set up payments approvals + deposits + mockUSDFC.safeTransfer(client, 10_000e18); + mockUSDC.safeTransfer(client, 10_000e6); + + vm.startPrank(client); + payments.setOperatorApproval(mockUSDFC, address(service), true, 1000e18, 1000e18, 365 days); + mockUSDFC.approve(address(payments), 1000e18); + payments.deposit(mockUSDFC, client, 1000e18); + + payments.setOperatorApproval(mockUSDC, address(service), true, 1000e6, 1000e6, 365 days); + mockUSDC.approve(address(payments), 1000e6); + payments.deposit(mockUSDC, client, 1000e6); + vm.stopPrank(); + } + + // ==================== Helpers ==================== + + function makeSignaturePass(address signer) public { + vm.mockCall(address(0x01), bytes(hex""), abi.encode(signer)); + } + + function createDataSet(string[] memory metadataKeys, string[] memory metadataValues) internal returns (uint256) { + bytes memory encodedData = + abi.encode(client, nextClientDataSetId++, metadataKeys, metadataValues, FAKE_SIGNATURE); + makeSignaturePass(client); + vm.prank(serviceProvider); + return mockPDPVerifier.createDataSet(service, encodedData); + } + + function usdcMetadata() internal pure returns (string[] memory keys, string[] memory values) { + keys = new string[](1); + values = new string[](1); + keys[0] = "paymentToken"; + values[0] = "USDC"; + } + + function noMetadata() internal pure returns (string[] memory keys, string[] memory values) { + keys = new string[](0); + values = new string[](0); + } + + /// Adds a single 1 TiB piece (height 35 => 2**35 leaves => 2**40 raw bytes) + function addOneTiBPiece(uint256 dataSetId) internal { + Cids.Cid[] memory pieceData = new Cids.Cid[](1); + pieceData[0] = Cids.CommPv2FromDigest(0, 35, keccak256(abi.encodePacked("1tib_piece", dataSetId))); + string[] memory emptyKeys = new string[](0); + string[] memory emptyValues = new string[](0); + makeSignaturePass(client); + mockPDPVerifier.addPieces(service, dataSetId, 0, pieceData, nextNonce++, FAKE_SIGNATURE, emptyKeys, emptyValues); + } + + function networkFee(uint256 amount) internal pure returns (uint256) { + return (amount + 199) / 200; // ceil(amount * 1/200), mirrors FilecoinPayV1 + } + + function commissionOn(uint256 amount, uint256 bps) internal pure returns (uint256) { + return ((amount - networkFee(amount)) * bps) / COMMISSION_MAX_BPS; + } + + // ==================== Token selection ==================== + + function testUSDCDataSetCreatesUSDCRailWithCommission() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + + vm.expectEmit(true, false, false, true); + emit FilecoinWarmStorageService.PaymentTokenSelected(1, mockUSDC, USDC_SERVICE_COMMISSION_BPS); + uint256 dataSetId = createDataSet(keys, values); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); + assertEq(address(pdpRail.token), address(mockUSDC), "PDP rail should be denominated in USDC"); + assertEq(pdpRail.commissionRateBps, USDC_SERVICE_COMMISSION_BPS, "NVAF commission should be locked in"); + assertEq(pdpRail.serviceFeeRecipient, address(payments), "commission should accrue to FilecoinPay itself"); + assertEq(pdpRail.lockupFixed, USDC_LIFECYCLE_RESERVE_TARGET, "lifecycle reserve in 6-decimal units"); + + assertEq(info.commissionBps, USDC_SERVICE_COMMISSION_BPS, "data set should record the commission"); + assertEq(address(viewContract.getDataSetPaymentToken(dataSetId)), address(mockUSDC), "stored payment token"); + } + + function testUSDCDataSetWithCDNAppliesCommissionToAllRails() public { + string[] memory keys = new string[](2); + string[] memory values = new string[](2); + keys[0] = "paymentToken"; + values[0] = "USDC"; + keys[1] = "withCDN"; + values[1] = "true"; + + uint256 dataSetId = createDataSet(keys, values); + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + FilecoinPayV1.RailView memory cacheMissRail = payments.getRail(info.cacheMissRailId); + assertEq(address(cacheMissRail.token), address(mockUSDC), "cache-miss rail in USDC"); + assertEq(cacheMissRail.commissionRateBps, USDC_SERVICE_COMMISSION_BPS, "NVAF on cache-miss rail"); + assertEq(cacheMissRail.serviceFeeRecipient, address(payments), "FilecoinPay on cache-miss rail"); + + FilecoinPayV1.RailView memory cdnRail = payments.getRail(info.cdnRailId); + assertEq(address(cdnRail.token), address(mockUSDC), "CDN rail in USDC"); + assertEq(cdnRail.commissionRateBps, USDC_SERVICE_COMMISSION_BPS, "NVAF on CDN rail"); + assertEq(cdnRail.serviceFeeRecipient, address(payments), "FilecoinPay on CDN rail"); + } + + function testUSDFCDataSetUnchangedByDefault() public { + (string[] memory keys, string[] memory values) = noMetadata(); + uint256 dataSetId = createDataSet(keys, values); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); + assertEq(address(pdpRail.token), address(mockUSDFC), "default rail stays USDFC"); + assertEq(pdpRail.commissionRateBps, 0, "USDFC rails carry no commission"); + assertEq(pdpRail.lockupFixed, LIFECYCLE_RESERVE_TARGET, "18-decimal lifecycle reserve"); + assertEq(info.commissionBps, 0, "no commission recorded"); + assertEq(address(viewContract.getDataSetPaymentToken(dataSetId)), address(mockUSDFC), "stored token is USDFC"); + } + + function testExplicitUSDFCKeywordSelectsDefault() public { + string[] memory keys = new string[](1); + string[] memory values = new string[](1); + keys[0] = "paymentToken"; + values[0] = "USDFC"; + uint256 dataSetId = createDataSet(keys, values); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); + assertEq(address(pdpRail.token), address(mockUSDFC), "explicit USDFC keyword"); + assertEq(pdpRail.commissionRateBps, 0, "no commission"); + } + + function testUnknownPaymentTokenReverts() public { + string[] memory keys = new string[](1); + string[] memory values = new string[](1); + keys[0] = "paymentToken"; + values[0] = "DOGE"; + + bytes memory encodedData = abi.encode(client, nextClientDataSetId++, keys, values, FAKE_SIGNATURE); + makeSignaturePass(client); + vm.prank(serviceProvider); + vm.expectRevert(abi.encodeWithSelector(Errors.UnsupportedPaymentToken.selector, "DOGE")); + mockPDPVerifier.createDataSet(service, encodedData); + } + + function testUSDCRevertsWhenDisabled() public { + // Deployment without a USDC instance + FilecoinWarmStorageService disabledImpl = new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + MockERC20(address(0)), // USDC disabled + filBeamBeneficiary, + serviceProviderRegistry, + sessionKeyRegistry, + 4 + ); + bytes memory initializeData = abi.encodeWithSelector( + FilecoinWarmStorageService.initialize.selector, + uint64(2880), + uint256(60), + filBeamController, + "USDFC-only deployment", + "No USDC configured" + ); + FilecoinWarmStorageService disabledService = + FilecoinWarmStorageService(address(new MyERC1967Proxy(address(disabledImpl), initializeData))); + disabledService.addApprovedProvider(1); + + (string[] memory keys, string[] memory values) = usdcMetadata(); + bytes memory encodedData = abi.encode(client, nextClientDataSetId++, keys, values, FAKE_SIGNATURE); + makeSignaturePass(client); + vm.prank(serviceProvider); + vm.expectRevert(abi.encodeWithSelector(Errors.UnsupportedPaymentToken.selector, "USDC")); + mockPDPVerifier.createDataSet(disabledService, encodedData); + } + + // ==================== Constructor validation ==================== + + function testConstructorRejectsWrongUSDCDecimals() public { + MockERC20 eighteenDecimalToken = new MockERC20(); + vm.expectRevert(); + new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + eighteenDecimalToken, // 18 decimals, must be 6 + filBeamBeneficiary, + serviceProviderRegistry, + sessionKeyRegistry, + 4 + ); + } + + // ==================== Commission staging ==================== + + function testInitializeSetsDefaultCommission() public view { + assertEq(viewContract.getUSDCCommissionBps(), USDC_SERVICE_COMMISSION_BPS, "default NVAF"); + } + + function testSetUSDCCommissionBpsAppliesToNewDataSetsOnly() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 before = createDataSet(keys, values); + + vm.expectEmit(false, false, false, true); + emit FilecoinWarmStorageService.USDCCommissionBpsUpdated(USDC_SERVICE_COMMISSION_BPS, 150); + service.setUSDCCommissionBps(150); + assertEq(viewContract.getUSDCCommissionBps(), 150, "staged commission"); + + uint256 afterChange = createDataSet(keys, values); + + FilecoinWarmStorageService.DataSetInfoView memory beforeInfo = viewContract.getDataSet(before); + FilecoinWarmStorageService.DataSetInfoView memory afterInfo = viewContract.getDataSet(afterChange); + assertEq( + payments.getRail(beforeInfo.pdpRailId).commissionRateBps, + USDC_SERVICE_COMMISSION_BPS, + "existing rail keeps its commission" + ); + assertEq(payments.getRail(afterInfo.pdpRailId).commissionRateBps, 150, "new rail uses the staged commission"); + } + + function testSetUSDCCommissionBpsRejectsAboveCap() public { + vm.expectRevert( + abi.encodeWithSelector( + Errors.CommissionExceedsMaximum.selector, + Errors.CommissionType.Service, + MAX_USDC_SERVICE_COMMISSION_BPS, + MAX_USDC_SERVICE_COMMISSION_BPS + 1 + ) + ); + service.setUSDCCommissionBps(MAX_USDC_SERVICE_COMMISSION_BPS + 1); + } + + function testSetUSDCCommissionBpsOnlyOwner() public { + vm.prank(client); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, client)); + service.setUSDCCommissionBps(100); + } + + // ==================== Pricing ==================== + + function testUSDCStorageRateFor1TiB() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 dataSetId = createDataSet(keys, values); + addOneTiBPiece(dataSetId); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); + + uint256 expectedRate = usdcRatePerEpochFor1TiB(); + assertEq(pdpRail.paymentRate, expectedRate, "1 TiB USDC rate per epoch"); + // floor(127/128 * 5_102_041 / 86_400) = 58 size-based + 1 dataset-fee floor + // ($5/TiB/month base grossed up for the NVAF; 127/128 is the Fr32 raw-size factor) + assertEq(expectedRate, 59, "expected 59 microUSDC per epoch for 1 TiB"); + } + + /// The PDP rail rate for the 1 TiB test piece under the USDC price list + function usdcRatePerEpochFor1TiB() internal view returns (uint256) { + return storageSizeBasedRatePerEpoch(viewContract.getPriceListUSDC(), Cids.leafCountToRawSize(2 ** 35)); + } + + function testGetPriceListUSDCView() public view { + PriceList memory pl = viewContract.getPriceListUSDC(); + assertEq(address(pl.token), address(mockUSDC), "token populated from immutable"); + assertEq(pl.rates.storagePerTibPerMonth, USDC_STORAGE_PRICE_PER_TIB_PER_MONTH, "grossed-up storage price"); + assertEq(pl.rates.datasetFeePerMonth, USDC_DATASET_FEE_PER_MONTH, "dataset fee at quantization floor"); + assertEq(pl.fees.createDataSetFee, USDC_CREATE_DATA_SET_FEE, "grossed-up create fee"); + assertEq(pl.lockups.lifecycleReserveTarget, USDC_LIFECYCLE_RESERVE_TARGET, "reserve target"); + + // The USDFC list is untouched + PriceList memory usdfcList = viewContract.getPriceList(); + assertEq(address(usdfcList.token), address(mockUSDFC), "USDFC list token"); + } + + // ==================== Commission flow on settlement ==================== + + /// Drives a USDC data set through creation, piece-add, a proven period, and settlement; + /// returns the rail and amounts observed. + function _settleProvenUSDCPeriod() internal returns (uint256 dataSetId, uint256 settled, uint256 commission) { + (string[] memory keys, string[] memory values) = usdcMetadata(); + dataSetId = createDataSet(keys, values); + addOneTiBPiece(dataSetId); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + // One-time op fees (create + addPieces) were paid at piecesAdded time; their commission + // is already credited to FilecoinPay's own fee account, alongside the network fee. + uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_ADD_PIECES_BASE_FEE + USDC_ADD_PIECES_PER_PIECE_FEE; + uint256 opFeeAccrual = networkFee(opFees) + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS); + (uint256 feeFunds,,,) = payments.accounts(mockUSDC, address(payments)); + assertEq(feeFunds, opFeeAccrual, "op-fee NVAF accrues to FilecoinPay at piecesAdded"); + + // Start proving, prove the first period, then settle past its deadline + uint256 challengeEpoch = block.number + 2880 - 30; + mockPDPVerifier.nextProvingPeriod(service, dataSetId, challengeEpoch, 2 ** 35, ""); + + uint256 deadline = viewContract.provingDeadline(dataSetId); + vm.roll(deadline - 30); + vm.prank(address(mockPDPVerifier)); + service.possessionProven(dataSetId, 2 ** 35, 12345, 5); + + vm.roll(deadline + 1); + (uint256 totalSettledAmount,, uint256 totalOperatorCommission,,,) = + payments.settleRail(info.pdpRailId, deadline); + + return (dataSetId, totalSettledAmount, totalOperatorCommission); + } + + function testUSDCSettlementSkimsCommissionToFilecoinPay() public { + (, uint256 settled, uint256 commission) = _settleProvenUSDCPeriod(); + + // One full proven proving period at the 1 TiB USDC rate + assertEq(settled, usdcRatePerEpochFor1TiB() * 2880, "settled amount for one proven period"); + assertEq(commission, commissionOn(settled, USDC_SERVICE_COMMISSION_BPS), "2% NVAF after network fee"); + + // FilecoinPay's own fee account holds the op-fee accrual (asserted inside the helper) + // plus the streaming settlement's network fee and NVAF commission + uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_ADD_PIECES_BASE_FEE + USDC_ADD_PIECES_PER_PIECE_FEE; + (uint256 feeFunds,,,) = payments.accounts(mockUSDC, address(payments)); + assertEq( + feeFunds, + networkFee(opFees) + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS) + networkFee(settled) + commission, + "FilecoinPay fee account holds op-fee and settlement accruals" + ); + } + + function testUSDFCSettlementHasNoCommission() public { + (string[] memory keys, string[] memory values) = noMetadata(); + uint256 dataSetId = createDataSet(keys, values); + addOneTiBPiece(dataSetId); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + uint256 challengeEpoch = block.number + 2880 - 30; + mockPDPVerifier.nextProvingPeriod(service, dataSetId, challengeEpoch, 2 ** 35, ""); + uint256 deadline = viewContract.provingDeadline(dataSetId); + vm.roll(deadline - 30); + vm.prank(address(mockPDPVerifier)); + service.possessionProven(dataSetId, 2 ** 35, 12345, 5); + vm.roll(deadline + 1); + + (uint256 settled,, uint256 commission,,,) = payments.settleRail(info.pdpRailId, deadline); + assertGt(settled, 0, "USDFC settlement pays out"); + assertEq(commission, 0, "USDFC rails carry no commission"); + } + + // ==================== Commission burn via FilecoinPay's fee auction ==================== + + function testCommissionEntersFilecoinPayFeeAuction() public { + _settleProvenUSDCPeriod(); + + // Commission accrued to FilecoinPay's own account is indistinguishable from network + // fees: both sit in accounts[token][payments] and sell through the same rolling Dutch + // auction, armed by the first accrual. + (uint256 accrued,,,) = payments.accounts(mockUSDC, address(payments)); + assertGt(accrued, 0, "commission + network fee accrued to FilecoinPay"); + + (uint88 startPrice,) = payments.auctionInfo(mockUSDC); + assertEq(uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE), "fee auction armed at first price"); + } + + function testBurnForFeesBuysAccruedCommissionAndBurnsFIL() public { + _settleProvenUSDCPeriod(); + (uint256 available,,,) = payments.accounts(mockUSDC, address(payments)); + + address buyer = address(0xbeef); + vm.deal(buyer, 1 ether); + uint256 burnBalanceBefore = BURN_ADDRESS.balance; + + // Paying the auction price takes the accrual (network fee + NVAF) and burns the FIL + vm.prank(buyer); + payments.burnForFees{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available); + + assertEq(mockUSDC.balanceOf(buyer), available, "buyer receives the accrued tokens"); + (uint256 remaining,,,) = payments.accounts(mockUSDC, address(payments)); + assertEq(remaining, 0, "fee account emptied"); + assertEq(BURN_ADDRESS.balance - burnBalanceBefore, FIRST_AUCTION_START_PRICE, "FIL destroyed at the burn actor"); + } + + // ==================== Legacy data sets and token-drift guard ==================== + + function _paymentTokenSlot(uint256 dataSetId) internal pure returns (bytes32) { + return keccak256(abi.encode(dataSetId, uint256(23))); // DATA_SET_PAYMENT_TOKEN_SLOT + } + + function testLegacyDataSetWithoutStoredTokenResolvesToUSDFC() public { + // Simulate a data set created before multi-token support: stored payment token is zero + (string[] memory keys, string[] memory values) = noMetadata(); + uint256 dataSetId = createDataSet(keys, values); + vm.store(address(service), _paymentTokenSlot(dataSetId), bytes32(0)); + + assertEq( + address(viewContract.getDataSetPaymentToken(dataSetId)), + address(mockUSDFC), + "legacy data set resolves to USDFC" + ); + + // Fee paths use USDFC (18-decimal) pricing: adding a 1 TiB piece sets the USDFC rate + addOneTiBPiece(dataSetId); + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); + uint256 expectedRate = calculateStorageSizeBasedRatePerEpoch(Cids.leafCountToRawSize(2 ** 35)); + assertEq(pdpRail.paymentRate, expectedRate, "legacy data set streams at USDFC rates"); + + // FilecoinPay's fee account holds exactly the 0.5% network fee on the op fees — no NVAF + // commission accrues on legacy USDFC data sets + uint256 opFees = CREATE_DATA_SET_FEE + ADD_PIECES_BASE_FEE + ADD_PIECES_PER_PIECE_FEE; + (uint256 feeFunds,,,) = payments.accounts(mockUSDFC, address(payments)); + assertEq(feeFunds, networkFee(opFees), "only the network fee, no commission, on legacy USDFC data sets"); + } + + function testUnknownStoredRailTokenRevertsLoudly() public { + // Simulate upgrade drift: the stored token matches neither token immutable + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 dataSetId = createDataSet(keys, values); + address strayToken = address(0xdead); + vm.store(address(service), _paymentTokenSlot(dataSetId), bytes32(uint256(uint160(strayToken)))); + + Cids.Cid[] memory pieceData = new Cids.Cid[](1); + pieceData[0] = Cids.CommPv2FromDigest(0, 35, keccak256("drift_piece")); + string[] memory emptyKeys = new string[](0); + string[] memory emptyValues = new string[](0); + makeSignaturePass(client); + vm.expectRevert(abi.encodeWithSelector(Errors.UnknownRailToken.selector, strayToken)); + mockPDPVerifier.addPieces(service, dataSetId, 0, pieceData, nextNonce++, FAKE_SIGNATURE, emptyKeys, emptyValues); + } + + // ==================== USDC fee paths beyond piece adds ==================== + + function testUSDCConsentTerminationChargesTerminateFeeAndCommission() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 dataSetId = createDataSet(keys, values); + + // Consent termination (SP submits with payer signature) charges the terminate fee and + // flushes all pending one-time fees immediately + makeSignaturePass(client); + vm.prank(serviceProvider); + service.terminateService(dataSetId, abi.encode(FAKE_SIGNATURE)); + + uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_TERMINATE_FEE; + (uint256 feeFunds,,,) = payments.accounts(mockUSDC, address(payments)); + assertEq( + feeFunds, + networkFee(opFees) + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS), + "NVAF skimmed from USDC create+terminate fees" + ); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + assertGt(info.pdpEndEpoch, 0, "PDP rail terminated"); + } + + function testUSDCSchedulePieceRemovalsChargesUSDCFee() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 dataSetId = createDataSet(keys, values); + addOneTiBPiece(dataSetId); // flushes creation + add fees + + uint256[] memory pieceIds = new uint256[](1); + pieceIds[0] = 0; + makeSignaturePass(client); + mockPDPVerifier.piecesScheduledRemove(dataSetId, pieceIds, address(service), abi.encode(FAKE_SIGNATURE)); + + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + assertEq( + uint256(info.pendingOneTimePayments), + USDC_SCHEDULE_PIECE_REMOVALS_FEE, + "pending fee is the 6-decimal removal-scheduling fee" + ); + } + + // ==================== USDC CDN rails: lockups, top-up, settlement ==================== + + function testUSDCCDNLockupAmountsAndTopUp() public { + string[] memory keys = new string[](2); + string[] memory values = new string[](2); + keys[0] = "paymentToken"; + values[0] = "USDC"; + keys[1] = "withCDN"; + values[1] = "true"; + uint256 dataSetId = createDataSet(keys, values); + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + assertEq( + payments.getRail(info.cdnRailId).lockupFixed, + USDC_DEFAULT_CDN_LOCKUP_AMOUNT, + "6-decimal CDN lockup at creation" + ); + assertEq( + payments.getRail(info.cacheMissRailId).lockupFixed, + USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, + "6-decimal cache-miss lockup at creation" + ); + + vm.prank(client); + service.topUpCDNPaymentRails(dataSetId, 500_000, 200_000); + assertEq( + payments.getRail(info.cdnRailId).lockupFixed, + USDC_DEFAULT_CDN_LOCKUP_AMOUNT + 500_000, + "CDN lockup topped up in 6-decimal units" + ); + assertEq( + payments.getRail(info.cacheMissRailId).lockupFixed, + USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT + 200_000, + "cache-miss lockup topped up in 6-decimal units" + ); + } + + function testUSDCFilBeamSettlementSkimsCommission() public { + string[] memory keys = new string[](2); + string[] memory values = new string[](2); + keys[0] = "paymentToken"; + values[0] = "USDC"; + keys[1] = "withCDN"; + values[1] = "true"; + uint256 dataSetId = createDataSet(keys, values); + + (uint256 feeBefore,,,) = payments.accounts(mockUSDC, address(payments)); + (uint256 filBeamBefore,,,) = payments.accounts(mockUSDC, filBeamBeneficiary); + + uint256 cdnAmount = 400_000; // microUSDC, within the CDN lockup + uint256 cacheMissAmount = 100_000; // within the cache-miss lockup + vm.prank(filBeamController); + service.settleFilBeamPaymentRails(dataSetId, cdnAmount, cacheMissAmount); + + (uint256 feeAfter,,,) = payments.accounts(mockUSDC, address(payments)); + assertEq( + feeAfter - feeBefore, + networkFee(cdnAmount) + commissionOn(cdnAmount, USDC_SERVICE_COMMISSION_BPS) + networkFee(cacheMissAmount) + + commissionOn(cacheMissAmount, USDC_SERVICE_COMMISSION_BPS), + "network fee + NVAF skimmed from both FilBeam settlements" + ); + + (uint256 filBeamAfter,,,) = payments.accounts(mockUSDC, filBeamBeneficiary); + assertEq( + filBeamAfter - filBeamBefore, + cdnAmount - networkFee(cdnAmount) - commissionOn(cdnAmount, USDC_SERVICE_COMMISSION_BPS), + "FilBeam beneficiary nets the CDN amount less network fee and NVAF" + ); + } + + // ==================== USDC lifecycle close-out ==================== + + function testUSDCDataSetDeletionReturnsReserveAndClearsToken() public { + (string[] memory keys, string[] memory values) = usdcMetadata(); + uint256 dataSetId = createDataSet(keys, values); + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + // Payer-initiated termination flushes the creation fee as a one-time payment + vm.prank(client); + service.terminateService(dataSetId); + + FilecoinWarmStorageService.DataSetInfoView memory terminated = viewContract.getDataSet(dataSetId); + vm.roll(terminated.pdpEndEpoch + 1); + payments.settleRail(info.pdpRailId, terminated.pdpEndEpoch); + + vm.prank(serviceProvider); + mockPDPVerifier.deleteDataSet(service, dataSetId, bytes("")); + + // Stored token cleaned up alongside the rest of the data set state + assertEq(uint256(service.extsload(_paymentTokenSlot(dataSetId))), 0, "dataSetPaymentToken cleared on deletion"); + + // The payer spent exactly the creation fee; reserve and rate lockup fully released + (, uint256 funds, uint256 available,) = payments.getAccountInfoIfSettled(mockUSDC, client); + assertEq(funds, 1000e6 - USDC_CREATE_DATA_SET_FEE, "payer recovers everything but the creation fee"); + assertEq(available, funds, "no residual lockup after finalization"); + } + + // ==================== Funding requirements ==================== + + function testUSDCCreateRevertsWithExactRequiredLockup() public { + address poorClient = address(0xf9); + mockUSDC.safeTransfer(poorClient, 1e6); + vm.startPrank(poorClient); + payments.setOperatorApproval(mockUSDC, address(service), true, 1000e6, 1000e6, 365 days); + mockUSDC.approve(address(payments), 1e6); + payments.deposit(mockUSDC, poorClient, 100_000); // below the 186_400 requirement + vm.stopPrank(); + + // requiredLockup = dataset fee for the default lockup period + lifecycle reserve + uint256 requiredLockup = + (USDC_DATASET_FEE_PER_MONTH * 86_400) / EPOCHS_PER_MONTH + USDC_LIFECYCLE_RESERVE_TARGET; + + (string[] memory keys, string[] memory values) = usdcMetadata(); + bytes memory encodedData = abi.encode(poorClient, nextClientDataSetId++, keys, values, FAKE_SIGNATURE); + makeSignaturePass(poorClient); + vm.prank(serviceProvider); + vm.expectRevert( + abi.encodeWithSelector(Errors.InsufficientLockupFunds.selector, poorClient, requiredLockup, 100_000) + ); + mockPDPVerifier.createDataSet(service, encodedData); + } + + // ==================== Upgrade path ==================== + + function testMigrateBackfillsCommissionFromPreUpgradeState() public { + // Simulate a proxy upgraded from the single-token implementation: slot 24 was never + // initialized (initialize() in setUp wrote 200; zero it to reproduce the upgrade state) + vm.store(address(service), bytes32(uint256(24)), bytes32(0)); // USDC_COMMISSION_BPS_SLOT + assertEq(viewContract.getUSDCCommissionBps(), 0, "pre-upgrade state has no staged NVAF"); + + FilecoinWarmStorageService newImpl = new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + mockUSDC, + filBeamBeneficiary, + serviceProviderRegistry, + sessionKeyRegistry, + 5 + ); + + FilecoinWarmStorageService.PlannedUpgrade memory plan; + plan.nextImplementation = address(newImpl); + plan.afterEpoch = uint96(vm.getBlockNumber()) + 100; + service.announcePlannedUpgrade(plan); + vm.roll(plan.afterEpoch); + + vm.expectEmit(false, false, false, true, address(service)); + emit FilecoinWarmStorageService.USDCCommissionBpsUpdated(0, USDC_SERVICE_COMMISSION_BPS); + service.upgradeToAndCall( + address(newImpl), abi.encodeWithSelector(FilecoinWarmStorageService.migrate.selector, address(0)) + ); + + assertEq( + viewContract.getUSDCCommissionBps(), + USDC_SERVICE_COMMISSION_BPS, + "migrate backfills the default NVAF from the pre-upgrade zero state" + ); + } +} diff --git a/service_contracts/test/ProviderValidation.t.sol b/service_contracts/test/ProviderValidation.t.sol index 0848c8fb..e48eb742 100644 --- a/service_contracts/test/ProviderValidation.t.sol +++ b/service_contracts/test/ProviderValidation.t.sol @@ -74,6 +74,7 @@ contract ProviderValidationTest is MockFVMTest { address(pdpVerifier), address(payments), usdfc, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/mocks/SharedMocks.sol b/service_contracts/test/mocks/SharedMocks.sol index 680b8694..512a4aa9 100644 --- a/service_contracts/test/mocks/SharedMocks.sol +++ b/service_contracts/test/mocks/SharedMocks.sol @@ -8,9 +8,9 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER // Mock implementation of the USDFC token contract MockERC20 is IERC20, IERC20Metadata { - string private _name = "USD Filecoin"; - string private _symbol = "USDFC"; - uint8 private _decimals = 18; + string internal _name = "USD Filecoin"; + string internal _symbol = "USDFC"; + uint8 internal _decimals = 18; mapping(address => uint256) private _balances; mapping(address => mapping(address => uint256)) private _allowances; @@ -93,6 +93,17 @@ contract MockERC20 is IERC20, IERC20Metadata { } } +// Mock implementation of a bridged USDC token (axlUSDC) with 6 decimals. +// The parent constructor mints 1M * 10**18 base units to the deployer — far more than 1M +// "dollars" at 6 decimals, which is harmless for tests (deployer simply holds a large supply). +contract MockUSDC is MockERC20 { + constructor() { + _name = "Axelar Wrapped USDC"; + _symbol = "axlUSDC"; + _decimals = 6; + } +} + // MockPDPVerifier is used to simulate the PDPVerifier for our tests contract MockPDPVerifier { uint256 public nextDataSetId = 1; diff --git a/service_contracts/tools/UPGRADE-CHECKLIST.md b/service_contracts/tools/UPGRADE-CHECKLIST.md index 107eaf0c..864cfcb3 100644 --- a/service_contracts/tools/UPGRADE-CHECKLIST.md +++ b/service_contracts/tools/UPGRADE-CHECKLIST.md @@ -303,6 +303,9 @@ Use the deploy dry-run output to identify contracts that are `Up to date` versus +**Linked libraries (SignatureVerificationLib, Rails)** +- [ ] If the release changes the source of any externally linked library (`src/lib/SignatureVerificationLib.sol`, `src/lib/Rails.sol`), remove its address from `service_contracts/deployments.json` for each network so the deploy scripts redeploy it. Reusing a pinned library address after its ABI changed makes the new implementation delegatecall selectors the old library does not have — every core flow reverts until a correctly linked implementation is upgraded in. + **Calibnet FWSS Implementation** - [ ] Run [Deploy Contract workflow]({{DEPLOY_WORKFLOW_LINK}}) with `network=Calibnet`, `contract=FWSS Implementation`, `dry_run=true` - [ ] Re-run with `dry_run=false` diff --git a/service_contracts/tools/generate_view_contract.sh b/service_contracts/tools/generate_view_contract.sh index fa09ad71..0aeddfb2 100755 --- a/service_contracts/tools/generate_view_contract.sh +++ b/service_contracts/tools/generate_view_contract.sh @@ -12,6 +12,7 @@ echo 'import {FilecoinWarmStorageService} from "./FilecoinWarmStorageService.sol echo 'import {FilecoinWarmStorageServiceStateInternalLibrary} from "./lib/FilecoinWarmStorageServiceStateInternalLibrary.sol";' echo 'import {IPDPProvingSchedule} from "@pdp/IPDPProvingSchedule.sol";' echo 'import {PriceList} from "./lib/PriceList.sol";' +echo 'import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";' echo contract FilecoinWarmStorageServiceStateView is IPDPProvingSchedule { echo " using FilecoinWarmStorageServiceStateInternalLibrary for FilecoinWarmStorageService;" diff --git a/service_contracts/tools/warm-storage-deploy-all.sh b/service_contracts/tools/warm-storage-deploy-all.sh index fff16827..4f514b5e 100755 --- a/service_contracts/tools/warm-storage-deploy-all.sh +++ b/service_contracts/tools/warm-storage-deploy-all.sh @@ -69,6 +69,7 @@ case "$CHAIN" in NETWORK_NAME="mainnet" # Network-specific addresses for mainnet USDFC_TOKEN_ADDRESS="${USDFC_TOKEN_ADDRESS:-0x80B98d3aa09ffff255c3ba4A241111Ff1262F045}" + USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-0xEB466342C4d449BC9f53A865D5Cb90586f405215}" # axlUSDC # Default challenge and proving configuration for mainnet (production values) DEFAULT_CHALLENGE_FINALITY="150" # Production security value DEFAULT_MAX_PROVING_PERIOD="2880" # 2880 epochs on mainnet @@ -380,6 +381,11 @@ deploy_implementation_if_needed \ "src/lib/Rails.sol:Rails" \ "Rails" +# USDC is optional; the zero address disables it. USDC-rail commission accrues to the +# FilecoinPay contract itself, whose fee auction sells it for FIL and burns it. +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" +USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-$ZERO_ADDRESS}" + # Step 8: Deploy or use existing FilecoinWarmStorageService implementation # Set LIBRARIES variable for the deployment helper (comma-separated path:name:address) if [ -n "$FWSS_PROXY_ADDRESS" ]; then @@ -395,6 +401,7 @@ deploy_implementation_if_needed \ "pdp_verifier=$PDP_VERIFIER_PROXY_ADDRESS" \ "filecoin_pay=$FILECOIN_PAY_ADDRESS" \ "usdfc_token=$USDFC_TOKEN_ADDRESS" \ + "usdc_token=$USDC_TOKEN_ADDRESS" \ "filbeam_beneficiary=$FILBEAM_BENEFICIARY_ADDRESS" \ "service_provider_registry=$SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS" \ "session_key_registry=$SESSION_KEY_REGISTRY_ADDRESS" \ diff --git a/service_contracts/tools/warm-storage-deploy-implementation.sh b/service_contracts/tools/warm-storage-deploy-implementation.sh index 6234053f..db3084b2 100755 --- a/service_contracts/tools/warm-storage-deploy-implementation.sh +++ b/service_contracts/tools/warm-storage-deploy-implementation.sh @@ -70,7 +70,10 @@ if [ -z "$SESSION_KEY_REGISTRY_ADDRESS" ]; then exit 1 fi -# Set network-specific USDFC token address based on chain ID +# Set network-specific token addresses based on chain ID. The bridged USDC (6 decimals) is +# optional: the zero address disables USDC data sets. USDC-rail commission accrues to the +# FilecoinPay contract itself, whose fee auction sells it for FIL and burns it. +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" case "$CHAIN" in "31415926") # Devnet requires explicit USDFC_TOKEN_ADDRESS (mock token) @@ -79,12 +82,18 @@ case "$CHAIN" in echo "Please set USDFC_TOKEN_ADDRESS to your deployed MockUSDFC address" exit 1 fi + # USDC optional on devnet (deploy a 6-decimal mock and set USDC_TOKEN_ADDRESS to enable) + USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-$ZERO_ADDRESS}" ;; "314159") USDFC_TOKEN_ADDRESS="${USDFC_TOKEN_ADDRESS:-0xb3042734b608a1B16e9e86B374A3f3e389B4cDf0}" # calibnet + # No canonical axlUSDC on calibration; Axelar's testnet aUSDC (6 decimals) is + # 0xCb7996d51Ff923b2C6076d42C065a6ca000D32A1 — set USDC_TOKEN_ADDRESS explicitly to enable. + USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-$ZERO_ADDRESS}" ;; "314") USDFC_TOKEN_ADDRESS="${USDFC_TOKEN_ADDRESS:-0x80B98d3aa09ffff255c3ba4A241111Ff1262F045}" # mainnet + USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-0xEB466342C4d449BC9f53A865D5Cb90586f405215}" # mainnet axlUSDC ;; *) echo "Error: Unsupported network" @@ -120,6 +129,7 @@ deploy_implementation_if_needed \ "pdp_verifier=$PDP_VERIFIER_PROXY_ADDRESS" \ "filecoin_pay=$FILECOIN_PAY_ADDRESS" \ "usdfc_token=$USDFC_TOKEN_ADDRESS" \ + "usdc_token=$USDC_TOKEN_ADDRESS" \ "filbeam_beneficiary=$FILBEAM_BENEFICIARY_ADDRESS" \ "service_provider_registry=$SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS" \ "session_key_registry=$SESSION_KEY_REGISTRY_ADDRESS" \ @@ -151,4 +161,3 @@ else echo echo "⏭️ Skipping automatic verification (export AUTO_VERIFY=true to enable)" fi -