From fd45eb8b99d6642132762331ab296a17792ebb37 Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Thu, 11 Jun 2026 15:06:47 -0400 Subject: [PATCH 1/6] feat: axlUSDC payment rails + network value-accrual fee (FWSS v1.4.0) Adds bridged USDC (axlUSDC, 6 decimals) as a second payment token for FilecoinWarmStorageService data sets, and introduces a network value-accrual fee (NVAF) on USDC rails: a 200 bps operator commission, locked into each USDC data set's rails at creation, routed to a new ValueAccrualRouter contract, sold for native FIL through a recurring Dutch auction (the same mechanism FilecoinPay uses for its network fee), and burned to the burn actor (f099). USDFC data sets are unchanged and remain commission-free. - Token selection rides the payer-signed `paymentToken` metadata key, so the existing CreateDataSet EIP-712 signature covers the choice; no signature-format changes. - PriceListUSDC grosses SP-bound amounts up by 1/(1 - 2%) so the SP nets the USDFC-equivalent after the commission; the per-dataset fee sits at the 6-decimal per-epoch quantization floor ($0.0864/month). - The commission cap equals the gross-up (200 bps) so the SP-parity guarantee holds for every permitted owner setting. - Storage layout is append-only (two new slots); pre-upgrade data sets resolve to USDFC in all fee paths, and a stored token matching neither token immutable reverts loudly (UnknownRailToken). - Deploy scripts deploy the router and pass the new constructor args; mainnet defaults USDC to axlUSDC, calibration defaults to disabled. Removes calibnet's pinned RAILS_LIB_ADDRESS (Rails ABI changed) and adds a linked-library redeploy step to the upgrade checklist. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 47 + service_contracts/abi/Errors.abi.json | 65 ++ .../abi/FilecoinWarmStorageService.abi.json | 125 +++ ...oinWarmStorageServiceStateLibrary.abi.json | 165 ++++ ...lecoinWarmStorageServiceStateView.abi.json | 148 +++ service_contracts/deployments.json | 1 - service_contracts/src/Errors.sol | 29 +- .../src/FilecoinWarmStorageService.sol | 209 ++++- .../FilecoinWarmStorageServiceStateView.sol | 13 + service_contracts/src/ValueAccrualRouter.sol | 112 +++ .../lib/FilecoinWarmStorageServiceLayout.json | 32 + .../lib/FilecoinWarmStorageServiceLayout.sol | 3 + ...WarmStorageServiceStateInternalLibrary.sol | 49 +- ...FilecoinWarmStorageServiceStateLibrary.sol | 49 +- service_contracts/src/lib/PriceList.sol | 38 + service_contracts/src/lib/PriceListUSDC.sol | 96 ++ service_contracts/src/lib/PriceListUSDFC.sol | 22 +- service_contracts/src/lib/Rails.sol | 239 +++-- service_contracts/test/Abandonment.t.sol | 2 + .../test/FilecoinWarmStorageService.t.sol | 20 +- .../FilecoinWarmStorageServiceOwner.t.sol | 2 + .../test/MultiTokenValueAccrual.t.sol | 839 ++++++++++++++++++ .../test/ProviderValidation.t.sol | 2 + service_contracts/test/mocks/SharedMocks.sol | 22 +- service_contracts/tools/UPGRADE-CHECKLIST.md | 3 + .../tools/generate_view_contract.sh | 1 + .../tools/warm-storage-deploy-all.sh | 17 + .../warm-storage-deploy-implementation.sh | 29 +- 28 files changed, 2251 insertions(+), 128 deletions(-) create mode 100644 service_contracts/src/ValueAccrualRouter.sol create mode 100644 service_contracts/src/lib/PriceListUSDC.sol create mode 100644 service_contracts/test/MultiTokenValueAccrual.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de5c778..8df61a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,53 @@ 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, routed to a +new ValueAccrualRouter contract, sold for FIL by recurring Dutch auction, 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) with SP-bound amounts grossed up by 1/(1 − 2%) so the SP + nets the USDFC-equivalent after the NVAF; exposed via + `FilecoinWarmStorageServiceStateView.getPriceListUSDC()`. 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 `ValueAccrualRouter` as `serviceFeeRecipient`. + 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. +- `ValueAccrualRouter`: permissionless, ownerless terminal sink for the NVAF. Collects accrued + commission from FilecoinPay and sells it for native FIL through the same recurring Dutch-auction + mechanism as FilecoinPay's `burnForFees`; the buyer's FIL is burned via the burn actor. New + `CommissionCollected` and `CommissionBurned` events. +- `PaymentTokenSelected` event at data set creation, and + `FilecoinWarmStorageServiceStateView.getDataSetPaymentToken(dataSetId)` (pre-upgrade data sets + resolve to USDFC). + +### Changed +- `FilecoinWarmStorageService` constructor takes two new parameters after the USDFC token: the USDC + token address (zero address disables USDC data sets) and the `ValueAccrualRouter` address (required + when USDC is configured). +- `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 deploy the `ValueAccrualRouter` and pass the new constructor arguments; 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..7d62a7c9 100644 --- a/service_contracts/abi/Errors.abi.json +++ b/service_contracts/abi/Errors.abi.json @@ -148,6 +148,22 @@ } ] }, + { + "type": "error", + "name": "CommissionExceedsAvailable", + "inputs": [ + { + "name": "requested", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "available", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "CommissionExceedsMaximum", @@ -381,6 +397,22 @@ } ] }, + { + "type": "error", + "name": "InsufficientNativeTokenForBurn", + "inputs": [ + { + "name": "provided", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "required", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "InsufficientRateAllowance", @@ -646,6 +678,17 @@ } ] }, + { + "type": "error", + "name": "NativeBurnFailed", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "NextProvingPeriodAlreadyCalled", @@ -929,6 +972,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..db4f8884 100644 --- a/service_contracts/abi/FilecoinWarmStorageService.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageService.abi.json @@ -17,6 +17,16 @@ "type": "address", "internalType": "contract IERC20Metadata" }, + { + "name": "_usdc", + "type": "address", + "internalType": "contract IERC20Metadata" + }, + { + "name": "_valueAccrualRouter", + "type": "address", + "internalType": "address" + }, { "name": "_filBeamBeneficiaryAddress", "type": "address", @@ -658,6 +668,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "setUSDCCommissionBps", + "inputs": [ + { + "name": "newCommissionBps", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setViewContract", @@ -851,6 +874,19 @@ "outputs": [], "stateMutability": "payable" }, + { + "type": "function", + "name": "usdcTokenAddress", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20Metadata" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "usdfcTokenAddress", @@ -920,6 +956,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "valueAccrualRouter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "viewContractAddress", @@ -1201,6 +1250,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 +1382,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 +1579,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 +2202,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..06078327 100644 --- a/service_contracts/deployments.json +++ b/service_contracts/deployments.json @@ -139,7 +139,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..fb4799ef 100644 --- a/service_contracts/src/Errors.sol +++ b/service_contracts/src/Errors.sol @@ -28,7 +28,9 @@ library Errors { /// FilBeam beneficiary address FilBeamBeneficiary, /// View contract address - View + View, + /// ValueAccrualRouter contract address + ValueAccrualRouter } /// @notice Enumerates the types of commission rates used in the protocol @@ -359,4 +361,29 @@ 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); + + /// @notice Requested more accumulated commission than the router holds for this token + /// @param requested The requested token amount + /// @param available The token amount actually available + error CommissionExceedsAvailable(uint256 requested, uint256 available); + + /// @notice The native FIL sent does not meet the current auction price + /// @param provided The attoFIL provided + /// @param required The current auction price in attoFIL + error InsufficientNativeTokenForBurn(uint256 provided, uint256 required); + + /// @notice Burning native FIL via the burn actor failed + /// @param amount The attoFIL amount that failed to burn + error NativeBurnFailed(uint256 amount); } diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index cd045d54..97a548db 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -21,20 +21,19 @@ 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 +77,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 +128,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), + /// routed to the ValueAccrualRouter and burned. + 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 +241,11 @@ 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; + // Receives the USDC-rail commission (NVAF) as the rails' serviceFeeRecipient; sells it for + // FIL by Dutch auction and burns the FIL. Required when usdcTokenAddress is set. + address public immutable valueAccrualRouter; address public immutable filBeamBeneficiaryAddress; ServiceProviderRegistry public immutable serviceProviderRegistry; SessionKeyRegistry public immutable sessionKeyRegistry; @@ -304,6 +316,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 +348,8 @@ contract FilecoinWarmStorageService is address _pdpVerifierAddress, address _paymentsContractAddress, IERC20Metadata _usdfc, + IERC20Metadata _usdc, + address _valueAccrualRouter, address _filBeamBeneficiaryAddress, ServiceProviderRegistry _serviceProviderRegistry, SessionKeyRegistry _sessionKeyRegistry, @@ -344,6 +367,16 @@ 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))) { + require(_valueAccrualRouter != address(0), Errors.ZeroAddress(Errors.AddressField.ValueAccrualRouter)); + // Verify token decimals from the USDC token contract + require(USDC_TOKEN_DECIMALS == _usdc.decimals()); + } + usdcTokenAddress = _usdc; + valueAccrualRouter = _valueAccrualRouter; + require(_filBeamBeneficiaryAddress != address(0), Errors.ZeroAddress(Errors.AddressField.FilBeamBeneficiary)); filBeamBeneficiaryAddress = _filBeamBeneficiaryAddress; @@ -404,6 +437,7 @@ contract FilecoinWarmStorageService is maxProvingPeriod = _maxProvingPeriod; challengeWindowSize = _challengeWindowSize; + usdcCommissionBps = USDC_SERVICE_COMMISSION_BPS; } function announceUpgradePlan(address nextImplementation, uint96 delayEpochs) external { @@ -465,9 +499,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 +637,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 +671,23 @@ 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, routed to the ValueAccrualRouter. + (IERC20 railToken, uint256 commissionBps, address serviceFeeRecipient) = Rails.resolvePaymentToken( + createData.metadataKeys, + createData.metadataValues, + usdfcTokenAddress, + usdcTokenAddress, + usdcCommissionBps, + valueAccrualRouter, + 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 +695,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, + 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; @@ -657,7 +746,10 @@ contract FilecoinWarmStorageService is uint256 dataSetId, uint256, // deletedLeafCount, - not used bytes calldata // extraData, - not used - ) external onlyPDPVerifier { + ) + external + onlyPDPVerifier + { DataSetInfo storage info = dataSetInfo[dataSetId]; require(info.pdpRailId != 0, Errors.DataSetNotRegistered(dataSetId)); @@ -731,6 +823,7 @@ contract FilecoinWarmStorageService is processScheduledPieceMetadataRemovals(dataSetId); // Complete cleanup + delete dataSetPaymentToken[dataSetId]; delete dataSetInfo[dataSetId]; } @@ -799,8 +892,10 @@ contract FilecoinWarmStorageService is // Verify the signature verifyAddPiecesSignature(payer, info.clientDataSetId, pieceData, nonce, metadataKeys, metadataValues, signature); + (, uint256 addPiecesBaseFee, uint256 addPiecesPerPieceFee,,,) = + Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); 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,10 +965,17 @@ 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 - ); + (,,, uint256 schedulePieceRemovalsFee,,) = Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); + uint96 newPending = info.pendingOneTimePayments + uint96(schedulePieceRemovalsFee); + info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress) + .replenishReserve( + info.pdpRailId, + info.pdpEndEpoch, + info.lifecycleReserveBalance, + newPending, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); info.pendingOneTimePayments = newPending; // Queue piece IDs for metadata cleanup at nextProvingPeriod @@ -890,7 +992,10 @@ contract FilecoinWarmStorageService is uint256, /*challengedLeafCount*/ uint256, /*seed*/ uint256 challengeCount - ) external onlyPDPVerifier { + ) + external + onlyPDPVerifier + { requirePaymentNotBeyondEndEpoch(dataSetId); if (provenThisPeriod[dataSetId]) { @@ -1032,7 +1137,11 @@ contract FilecoinWarmStorageService is address, // oldServiceProvider address, // newServiceProvider bytes calldata // extraData - not used - ) external override onlyPDPVerifier { + ) + external + override + onlyPDPVerifier + { revert Errors.StorageProviderChangesNotSupported(); } @@ -1064,7 +1173,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(_paymentTokenOf(dataSetId), usdcTokenAddress); + info.pendingOneTimePayments += uint96(terminateFee); } else { require( msg.sender == info.payer || msg.sender == info.serviceProvider, @@ -1102,6 +1212,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; } @@ -1122,9 +1233,8 @@ contract FilecoinWarmStorageService is // Check if CDN rails are configured (presence of rails indicates CDN was set up) require(info.cdnRailId != 0 && info.cacheMissRailId != 0, Errors.InvalidDataSetId(dataSetId)); - FilecoinPayV1(paymentsContractAddress).settleCDNRails( - info.cdnRailId, info.cacheMissRailId, cdnAmount, cacheMissAmount - ); + FilecoinPayV1(paymentsContractAddress) + .settleCDNRails(info.cdnRailId, info.cacheMissRailId, cdnAmount, cacheMissAmount); } /** @@ -1146,9 +1256,16 @@ contract FilecoinWarmStorageService is // Check if cache miss and CDN rails are configured require(info.cacheMissRailId != 0 && info.cdnRailId != 0, Errors.InvalidDataSetId(dataSetId)); - FilecoinPayV1(paymentsContractAddress).topUpCDNRails( - dataSetId, info.cacheMissRailId, info.cdnRailId, cacheMissAmountToAdd, cdnAmountToAdd - ); + FilecoinPayV1(paymentsContractAddress) + .topUpCDNRails( + dataSetId, + info.cacheMissRailId, + info.cdnRailId, + cacheMissAmountToAdd, + cdnAmountToAdd, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); } function terminateCDNService(uint256 dataSetId) external onlyFilBeamController { @@ -1209,12 +1326,36 @@ contract FilecoinWarmStorageService is uint256 pdpRailId = info.pdpRailId; require(pdpRailId != 0, Errors.NoPDPPaymentRail(dataSetId)); - info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).updateStorageRates( - dataSetId, pdpRailId, leafCount, pending, reserveBalance, info.pdpEndEpoch, immediateTermination - ); + info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress) + .updateStorageRates( + dataSetId, + pdpRailId, + leafCount, + pending, + reserveBalance, + info.pdpEndEpoch, + immediateTermination, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); 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)); + } + } + function processScheduledPieceMetadataRemovals(uint256 dataSetId) internal returns (bool hadRemovals) { uint256[] storage pieceIds = scheduledPieceMetadataRemovals[dataSetId]; uint256 len = pieceIds.length; @@ -1541,9 +1682,7 @@ contract FilecoinWarmStorageService is // If no epochs are proven, no payment is due (but settlement may still advance) if (provenEpochCount == 0) { return ValidationResult({ - modifiedAmount: 0, - settleUpto: settleUpTo, - note: "No proven epochs in the requested range" + modifiedAmount: 0, settleUpto: settleUpTo, note: "No proven epochs in the requested range" }); } 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/ValueAccrualRouter.sol b/service_contracts/src/ValueAccrualRouter.sol new file mode 100644 index 00000000..e0bbeb6b --- /dev/null +++ b/service_contracts/src/ValueAccrualRouter.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.20; + +import {FVMPay} from "@fvm-solidity/FVMPay.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Dutch} from "@fws-payments/Dutch.sol"; +import {FIRST_AUCTION_START_PRICE, MAX_AUCTION_START_PRICE, FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; +import {Errors} from "./Errors.sol"; + +/// @title ValueAccrualRouter +/// @notice Terminal sink for the network value-accrual fee (NVAF) charged on FWSS USDC rails. +/// +/// FWSS sets this contract as the `serviceFeeRecipient` of USDC rails, so the rails' operator +/// commission accrues to this contract's account inside FilecoinPay. The accumulated tokens are +/// sold for native FIL through a recurring Dutch auction — the same mechanism FilecoinPay itself +/// uses for its network fee (`burnForFees`) — and the FIL paid by the buyer is destroyed via the +/// burn actor (f099). Buyback-and-burn without any DEX or price-oracle dependency: the decaying +/// price lets arbitrageurs compete the auction down to market rate. +/// +/// Fully permissionless and immutable: no owner, no parameters to govern. Tokens sent here can +/// only ever leave through the auction; the FIL paid for them is always burned. +contract ValueAccrualRouter is ReentrancyGuard { + using Dutch for uint256; + using SafeERC20 for IERC20; + + FilecoinPayV1 public immutable payments; + + // pack into one storage slot (mirrors FilecoinPayV1's fee auction) + struct AuctionInfo { + uint88 startPrice; // highest possible price is MAX_AUCTION_START_PRICE + uint168 startTime; + } + + mapping(IERC20 token => AuctionInfo) public auctionInfo; + + event CommissionCollected(IERC20 indexed token, uint256 amount); + event CommissionBurned( + IERC20 indexed token, address indexed buyer, address indexed recipient, uint256 tokenAmount, uint256 filBurned + ); + + constructor(FilecoinPayV1 _payments) { + require(address(_payments) != address(0), Errors.ZeroAddress(Errors.AddressField.FilecoinPayV1)); + payments = _payments; + } + + /// @notice Pulls this contract's accrued commission for `token` out of FilecoinPay and arms + /// the auction if it isn't already running. Callable by anyone; also runs + /// automatically at the start of every `burnForCommission`. + /// @param token The commission token to collect + /// @return collected The amount pulled from FilecoinPay (0 if nothing had accrued) + function collect(IERC20 token) external nonReentrant returns (uint256 collected) { + return _collect(token); + } + + function _collect(IERC20 token) internal returns (uint256 collected) { + (collected,,,) = payments.accounts(token, address(this)); + if (collected > 0) { + payments.withdraw(token, collected); + emit CommissionCollected(token, collected); + } + + // (Re)arm the auction whenever there is stock to sell and no live price. Mirrors + // FilecoinPay's fee auction lifecycle: a fully-decayed auction resets to zero and is + // re-armed at the first price on the next accrual. + if (token.balanceOf(address(this)) > 0) { + AuctionInfo storage auction = auctionInfo[token]; + if (auction.startPrice == 0) { + auction.startPrice = FIRST_AUCTION_START_PRICE; + auction.startTime = uint168(block.timestamp); + } + } + } + + /// @notice Burn FIL to buy the accumulated commission tokens. + /// @dev The price is for the lot, independent of `requested` — rational buyers take + /// everything available (same semantics as FilecoinPay's `burnForFees`). The price + /// decays by 3/4 every week; each purchase resets it to 4x the clearing price. + /// @param token Which commission token to buy + /// @param recipient Receives the purchased tokens + /// @param requested Exact amount of tokens transferred + function burnForCommission(IERC20 token, address recipient, uint256 requested) external payable nonReentrant { + _collect(token); + + uint256 available = token.balanceOf(address(this)); + require(requested <= available, Errors.CommissionExceedsAvailable(requested, available)); + + AuctionInfo storage auction = auctionInfo[token]; + uint256 auctionPrice = uint256(auction.startPrice).decay(block.timestamp - auction.startTime); + require(msg.value >= auctionPrice, Errors.InsufficientNativeTokenForBurn(msg.value, auctionPrice)); + + auctionPrice *= Dutch.RESET_FACTOR; + if (auctionPrice > MAX_AUCTION_START_PRICE) { + auctionPrice = MAX_AUCTION_START_PRICE; + } + auction.startPrice = uint88(auctionPrice); + auction.startTime = uint168(block.timestamp); + + require(FVMPay.burn(msg.value), Errors.NativeBurnFailed(msg.value)); + + token.safeTransfer(recipient, requested); + + emit CommissionBurned(token, msg.sender, recipient, requested, msg.value); + } + + /// @notice Current auction price (attoFIL) to take the accumulated `token` commission. + function currentPrice(IERC20 token) external view returns (uint256) { + AuctionInfo storage auction = auctionInfo[token]; + return uint256(auction.startPrice).decay(block.timestamp - auction.startTime); + } +} 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..daa08c4d 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol @@ -28,3 +28,6 @@ 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..1ec18007 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol @@ -7,15 +7,14 @@ pragma solidity ^0.8.20; import {Cids} from "@pdp/Cids.sol"; import {Errors} from "../Errors.sol"; -import { - CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService -} from "../FilecoinWarmStorageService.sol"; +import {CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService} from "../FilecoinWarmStorageService.sol"; import { DATASET_FEE_PER_MONTH, SERVICE_COMMISSION_BPS, 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; @@ -231,12 +230,12 @@ library FilecoinWarmStorageServiceStateInternalLibrary { returns (bool) { return uint256( - service.extsload( + service.extsload( keccak256( - abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) - ) + abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) + ) ) - ) & (1 << (periodId & 255)) != 0; + ) & (1 << (periodId & 255)) != 0; } function provingActivationEpoch(FilecoinWarmStorageService service, uint256 dataSetId) @@ -654,4 +653,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..06e92d14 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol @@ -3,15 +3,14 @@ pragma solidity ^0.8.20; import {Cids} from "@pdp/Cids.sol"; import {Errors} from "../Errors.sol"; -import { - CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService -} from "../FilecoinWarmStorageService.sol"; +import {CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService} from "../FilecoinWarmStorageService.sol"; import { DATASET_FEE_PER_MONTH, SERVICE_COMMISSION_BPS, 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; @@ -223,12 +222,12 @@ library FilecoinWarmStorageServiceStateLibrary { returns (bool) { return uint256( - service.extsload( + service.extsload( keccak256( - abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) - ) + abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) + ) ) - ) & (1 << (periodId & 255)) != 0; + ) & (1 << (periodId & 255)) != 0; } function provingActivationEpoch(FilecoinWarmStorageService service, uint256 dataSetId) @@ -646,4 +645,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..4b4caef1 --- /dev/null +++ b/service_contracts/src/lib/PriceListUSDC.sol @@ -0,0 +1,96 @@ +// 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_DAY, + 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. +// +// Two deliberate differences from the USDFC list: +// +// 1. Gross-up. USDC rails carry a network value-accrual fee (NVAF) as the rail's operator +// commission, routed to the ValueAccrualRouter and burned. All SP-bound amounts below are +// grossed up by 1/(1 - 2%) — rounded up — so the SP nets the USDFC-equivalent amount after +// the commission; the customer bears the NVAF as a posted-price difference, and USDFC keeps +// a real discount. (The 0.5% Filecoin Pay network fee applies identically on both tokens, so +// it does not enter the gross-up.) +// +// 2. 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 +// (< ~35 GiB) 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 = 2_551_021; // 2.5 / 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 +// USDFC-equivalent. 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..db0ad6ff 100644 --- a/service_contracts/src/lib/PriceListUSDFC.sol +++ b/service_contracts/src/lib/PriceListUSDFC.sol @@ -3,17 +3,21 @@ 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_DAY, + EPOCHS_PER_MONTH, + GIB_IN_BYTES, + MIB_IN_BYTES, + 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..33f384b8 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -4,19 +4,16 @@ 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 {EPOCHS_PER_MONTH, PriceList, storageRatePerEpoch} from "./PriceList.sol"; +import {SERVICE_COMMISSION_BPS, priceList as priceListUSDFC} from "./PriceListUSDFC.sol"; +import {priceListUSDC} from "./PriceListUSDC.sol"; + +// Metadata key selecting the rail token at data set creation; the value is signed by the payer +// along with the rest of the metadata. Absent means USDFC. +uint256 constant METADATA_KEY_PAYMENT_TOKEN_SIZE = 12; +bytes32 constant METADATA_KEY_PAYMENT_TOKEN_HASH = keccak256("paymentToken"); +bytes32 constant PAYMENT_TOKEN_VALUE_USDC_HASH = keccak256("USDC"); +bytes32 constant PAYMENT_TOKEN_VALUE_USDFC_HASH = keccak256("USDFC"); event CDNPaymentRailsToppedUp( uint256 indexed dataSetId, @@ -35,34 +32,119 @@ 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 applying to `token` for this deployment. + /// @dev Token-keyed dispatch: the USDC list when `token` is the deployment's configured USDC + /// instance, the USDFC list otherwise. Lives here (external library) to keep the per-token + /// price constants out of the main contract's code size. `pl.token` is populated with + /// `token`. + function priceListFor(IERC20 token, IERC20 usdc) public pure returns (PriceList memory pl) { + if (address(usdc) != address(0) && token == usdc) { + pl = priceListUSDC(); + } else { + pl = priceListUSDFC(); + } + pl.token = token; + } + + /// @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, routed to the ValueAccrualRouter. 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 valueAccrualRouter Receives USDC-rail commission (burned for FIL) + /// @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 valueAccrualRouter, + 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 == METADATA_KEY_PAYMENT_TOKEN_SIZE + && keccak256(keyBytes) == METADATA_KEY_PAYMENT_TOKEN_HASH + ) { + bytes32 valueHash = keccak256(bytes(metadataValues[i])); + if (valueHash == PAYMENT_TOKEN_VALUE_USDC_HASH) { + if (address(usdc) == address(0)) { + revert Errors.UnsupportedPaymentToken(metadataValues[i]); + } + return (usdc, usdcCommissionBps, valueAccrualRouter); + } + if (valueHash != PAYMENT_TOKEN_VALUE_USDFC_HASH) { + 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 `token`, as flat words. + /// @dev Leaner for the main contract to decode than the full PriceList struct (code size). + function oneTimeFees(IERC20 token, IERC20 usdc) + public + pure + returns ( + uint256 createDataSetFee, + uint256 addPiecesBaseFee, + uint256 addPiecesPerPieceFee, + uint256 schedulePieceRemovalsFee, + uint256 terminateFee, + uint256 lifecycleReserveTarget + ) + { + PriceList memory pl = priceListFor(token, 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 +155,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 +163,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 +177,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, + IERC20 usdc + ) + public + returns ( + uint256 pdpRailId, + uint256 cacheMissRailId, + uint256 cdnRailId, + uint256 createDataSetFee, + uint256 lifecycleReserveTarget + ) + { + PriceList memory pl = priceListFor(token, 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 +324,11 @@ library Rails { uint256 cacheMissRailId, uint256 cdnRailId, uint256 cacheMissAmountToAdd, - uint256 cdnAmountToAdd + uint256 cdnAmountToAdd, + IERC20 token, + IERC20 usdc ) public { + PriceList memory pl = priceListFor(token, 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 +346,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 +369,42 @@ 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. The internal variant stays inlined into in-library callers. + function replenishReserve( + FilecoinPayV1 payments, + uint256 pdpRailId, + uint256 pdpEndEpoch, + uint96 reserveBalance, + uint96 pending, + IERC20 token, + IERC20 usdc + ) public returns (uint96) { + return replenishReserveIfNeeded( + payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, priceListFor(token, usdc) + ); + } + function updateStorageRates( FilecoinPayV1 payments, uint256 dataSetId, @@ -294,15 +413,19 @@ library Rails { uint96 pending, uint96 reserveBalance, uint256 pdpEndEpoch, - bool immediateTermination + bool immediateTermination, + IERC20 token, + IERC20 usdc ) public returns (uint96 newReserveBalance) { - uint256 newStorageRatePerEpoch = calculateStorageRate(leafCount); + PriceList memory pl = priceListFor(token, 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..07a6c4f1 100644 --- a/service_contracts/test/Abandonment.t.sol +++ b/service_contracts/test/Abandonment.t.sol @@ -102,6 +102,8 @@ contract AbandonmentTest is MockFVMTest { address(pdpVerifier), address(payments), usdfc, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/FilecoinWarmStorageService.t.sol b/service_contracts/test/FilecoinWarmStorageService.t.sol index b89fc3f3..e8107202 100644 --- a/service_contracts/test/FilecoinWarmStorageService.t.sol +++ b/service_contracts/test/FilecoinWarmStorageService.t.sol @@ -222,6 +222,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -307,6 +309,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -340,6 +344,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -363,6 +369,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -386,6 +394,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -419,6 +429,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -461,6 +473,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -500,6 +514,8 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -5500,6 +5516,8 @@ contract FilecoinWarmStorageServiceUpgradeTest is Test { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -5637,7 +5655,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..2736b92d 100644 --- a/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol +++ b/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol @@ -97,6 +97,8 @@ contract FilecoinWarmStorageServiceOwnerTest is MockFVMTest { address(pdpVerifier), address(payments), usdfcToken, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter 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..2c0406cb --- /dev/null +++ b/service_contracts/test/MultiTokenValueAccrual.t.sol @@ -0,0 +1,839 @@ +// 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 {Dutch} from "@fws-payments/Dutch.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 {ValueAccrualRouter} from "../src/ValueAccrualRouter.sol"; +import {Errors} from "../src/Errors.sol"; +import {ServiceProviderRegistry} from "../src/ServiceProviderRegistry.sol"; +import {ServiceProviderRegistryStorage} from "../src/ServiceProviderRegistryStorage.sol"; +import {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} 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 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; + ValueAccrualRouter public router; + 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(); + router = new ValueAccrualRouter(payments); + + FilecoinWarmStorageService serviceImpl = new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + mockUSDC, + address(router), + 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.transfer(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(router), "commission should route to the ValueAccrualRouter"); + 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(router), "router 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(router), "router 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 + address(0), // no ValueAccrualRouter + 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 testConstructorRequiresRouterWhenUSDCConfigured() public { + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, Errors.AddressField.ValueAccrualRouter)); + new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + mockUSDC, + address(0), // missing router + filBeamBeneficiary, + serviceProviderRegistry, + sessionKeyRegistry, + 4 + ); + } + + function testConstructorRejectsWrongUSDCDecimals() public { + MockERC20 eighteenDecimalToken = new MockERC20(); + vm.expectRevert(); + new FilecoinWarmStorageService( + address(mockPDPVerifier), + address(payments), + mockUSDFC, + eighteenDecimalToken, // 18 decimals, must be 6 + address(router), + 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); + + // floor(2_551_021 / 86_400) = 29 size-based + 1 dataset-fee floor = 30 microUSDC/epoch + uint256 expectedRate = + USDC_STORAGE_PRICE_PER_TIB_PER_MONTH / EPOCHS_PER_MONTH + USDC_DATASET_FEE_PER_MONTH / EPOCHS_PER_MONTH; + assertEq(pdpRail.paymentRate, expectedRate, "1 TiB USDC rate per epoch"); + assertEq(expectedRate, 30, "expected 30 microUSDC per epoch for 1 TiB"); + } + + 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 the router. + uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_ADD_PIECES_BASE_FEE + USDC_ADD_PIECES_PER_PIECE_FEE; + uint256 opFeeCommission = commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS); + (uint256 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); + assertEq(routerFunds, opFeeCommission, "op-fee commission accrues to router 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 testUSDCSettlementSkimsCommissionToRouter() public { + (, uint256 settled, uint256 commission) = _settleProvenUSDCPeriod(); + + // One full proven proving period at 30 microUSDC/epoch + assertEq(settled, 30 * 2880, "settled amount for one proven period"); + assertEq(commission, commissionOn(settled, USDC_SERVICE_COMMISSION_BPS), "2% NVAF after network fee"); + + // Router account holds the op-fee commission (asserted inside the helper) plus the + // streaming-settlement commission + uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_ADD_PIECES_BASE_FEE + USDC_ADD_PIECES_PER_PIECE_FEE; + (uint256 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); + assertEq( + routerFunds, + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS) + commission, + "router account holds op-fee and settlement commission" + ); + } + + 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"); + } + + // ==================== ValueAccrualRouter ==================== + + function testRouterCollectPullsCommission() public { + _settleProvenUSDCPeriod(); + + (uint256 accrued,,,) = payments.accounts(mockUSDC, address(router)); + assertGt(accrued, 0, "commission accrued in payments"); + + vm.expectEmit(true, false, false, true); + emit ValueAccrualRouter.CommissionCollected(mockUSDC, accrued); + uint256 collected = router.collect(mockUSDC); + + assertEq(collected, accrued, "collect returns the pulled amount"); + assertEq(mockUSDC.balanceOf(address(router)), accrued, "router holds the tokens"); + (uint256 remaining,,,) = payments.accounts(mockUSDC, address(router)); + assertEq(remaining, 0, "payments account drained"); + + (uint88 startPrice,) = router.auctionInfo(mockUSDC); + assertEq(uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE), "auction armed at first price"); + } + + function testRouterBurnForCommissionBurnsFILAndPaysTokens() public { + _settleProvenUSDCPeriod(); + router.collect(mockUSDC); + uint256 available = mockUSDC.balanceOf(address(router)); + + address buyer = address(0xbeef); + vm.deal(buyer, 1 ether); + uint256 burnBalanceBefore = BURN_ADDRESS.balance; + + // Underpaying the auction price reverts + vm.prank(buyer); + vm.expectRevert( + abi.encodeWithSelector( + Errors.InsufficientNativeTokenForBurn.selector, FIRST_AUCTION_START_PRICE - 1, FIRST_AUCTION_START_PRICE + ) + ); + router.burnForCommission{value: FIRST_AUCTION_START_PRICE - 1}(mockUSDC, buyer, available); + + // Requesting more than available reverts + vm.prank(buyer); + vm.expectRevert(abi.encodeWithSelector(Errors.CommissionExceedsAvailable.selector, available + 1, available)); + router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available + 1); + + // Paying the price takes the lot and burns the FIL + vm.prank(buyer); + router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available); + + assertEq(mockUSDC.balanceOf(buyer), available, "buyer receives the commission tokens"); + assertEq(mockUSDC.balanceOf(address(router)), 0, "router emptied"); + assertEq(BURN_ADDRESS.balance - burnBalanceBefore, FIRST_AUCTION_START_PRICE, "FIL destroyed at the burn actor"); + + (uint88 startPrice,) = router.auctionInfo(mockUSDC); + assertEq( + uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE) * Dutch.RESET_FACTOR, "auction price reset to 4x" + ); + } + + function testRouterBurnCollectsImplicitly() public { + // burnForCommission pulls pending commission from payments without a prior collect() + _settleProvenUSDCPeriod(); + (uint256 accrued,,,) = payments.accounts(mockUSDC, address(router)); + + address buyer = address(0xbeef); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, accrued); + assertEq(mockUSDC.balanceOf(buyer), accrued, "implicit collect during burn"); + } + + function testRouterAuctionPriceDecays() public { + _settleProvenUSDCPeriod(); + router.collect(mockUSDC); + + uint256 priceAtStart = router.currentPrice(mockUSDC); + assertEq(priceAtStart, FIRST_AUCTION_START_PRICE, "starts at first auction price"); + + vm.warp(block.timestamp + 3.5 days); + uint256 priceAfterHalving = router.currentPrice(mockUSDC); + assertEq(priceAfterHalving, FIRST_AUCTION_START_PRICE / 2, "halves per 3.5 days"); + + // A fully decayed auction clears at zero, and the next collect re-arms it + vm.warp(block.timestamp + 365 days); + assertEq(router.currentPrice(mockUSDC), 0, "fully decayed"); + + address buyer = address(0xbeef); + uint256 available = mockUSDC.balanceOf(address(router)); + vm.prank(buyer); + router.burnForCommission(mockUSDC, buyer, available); + assertEq(mockUSDC.balanceOf(buyer), available, "free claim after full decay"); + } + + function testRouterConstructorRejectsZeroPayments() public { + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, Errors.AddressField.FilecoinPayV1)); + new ValueAccrualRouter(FilecoinPayV1(address(0))); + } + + // ==================== 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"); + (uint256 routerFunds,,,) = payments.accounts(mockUSDFC, address(router)); + assertEq(routerFunds, 0, "no commission accrues 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 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); + assertEq( + routerFunds, + 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 routerBefore,,,) = payments.accounts(mockUSDC, address(router)); + (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 routerAfter,,,) = payments.accounts(mockUSDC, address(router)); + assertEq( + routerAfter - routerBefore, + commissionOn(cdnAmount, USDC_SERVICE_COMMISSION_BPS) + + commissionOn(cacheMissAmount, USDC_SERVICE_COMMISSION_BPS), + "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.transfer(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, + address(router), + 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..2f52eba9 100644 --- a/service_contracts/test/ProviderValidation.t.sol +++ b/service_contracts/test/ProviderValidation.t.sol @@ -74,6 +74,8 @@ contract ProviderValidationTest is MockFVMTest { address(pdpVerifier), address(payments), usdfc, + MockERC20(address(0)), // USDC disabled + address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/mocks/SharedMocks.sol b/service_contracts/test/mocks/SharedMocks.sol index 680b8694..97b976d0 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; @@ -219,9 +230,8 @@ contract MockPDPVerifier { // Call the listener's storageProviderChanged function if (listenerAddr != address(0)) { - PDPListener(listenerAddr).storageProviderChanged( - dataSetId, oldServiceProvider, newServiceProvider, extraData - ); + PDPListener(listenerAddr) + .storageProviderChanged(dataSetId, oldServiceProvider, newServiceProvider, extraData); } emit DataSetServiceProviderChanged(dataSetId, oldServiceProvider, newServiceProvider); diff --git a/service_contracts/tools/UPGRADE-CHECKLIST.md b/service_contracts/tools/UPGRADE-CHECKLIST.md index 107eaf0c..ef3cb461 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, ValueAccrualRouter)** +- [ ] If the release changes the source of any externally linked library (`src/lib/SignatureVerificationLib.sol`, `src/lib/Rails.sol`) or a companion contract the implementation depends on (`src/ValueAccrualRouter.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..83a4b8d4 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,20 @@ deploy_implementation_if_needed \ "src/lib/Rails.sol:Rails" \ "Rails" +# Step 7b: Deploy or use existing ValueAccrualRouter (only when USDC is enabled). +# Receives the USDC-rail commission, sells it for FIL via Dutch auction, burns the FIL. +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" +USDC_TOKEN_ADDRESS="${USDC_TOKEN_ADDRESS:-$ZERO_ADDRESS}" +if [ "$USDC_TOKEN_ADDRESS" != "$ZERO_ADDRESS" ]; then + deploy_implementation_if_needed \ + "VALUE_ACCRUAL_ROUTER_ADDRESS" \ + "src/ValueAccrualRouter.sol:ValueAccrualRouter" \ + "ValueAccrualRouter" \ + "$FILECOIN_PAY_ADDRESS" +else + VALUE_ACCRUAL_ROUTER_ADDRESS="$ZERO_ADDRESS" +fi + # 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 +410,8 @@ 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" \ + "value_accrual_router=$VALUE_ACCRUAL_ROUTER_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..538e10e1 100755 --- a/service_contracts/tools/warm-storage-deploy-implementation.sh +++ b/service_contracts/tools/warm-storage-deploy-implementation.sh @@ -70,7 +70,9 @@ 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. +ZERO_ADDRESS="0x0000000000000000000000000000000000000000" case "$CHAIN" in "31415926") # Devnet requires explicit USDFC_TOKEN_ADDRESS (mock token) @@ -79,12 +81,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" @@ -107,6 +115,18 @@ deploy_implementation_if_needed \ "src/lib/Rails.sol:Rails" \ "Rails" +# The ValueAccrualRouter receives the USDC-rail commission (network value-accrual fee), sells +# it for FIL via Dutch auction, and burns the FIL. Required whenever USDC is enabled. +if [ "$USDC_TOKEN_ADDRESS" != "$ZERO_ADDRESS" ]; then + deploy_implementation_if_needed \ + "VALUE_ACCRUAL_ROUTER_ADDRESS" \ + "src/ValueAccrualRouter.sol:ValueAccrualRouter" \ + "ValueAccrualRouter" \ + "filecoin_pay=$FILECOIN_PAY_ADDRESS" +else + VALUE_ACCRUAL_ROUTER_ADDRESS="$ZERO_ADDRESS" +fi + if [ -n "$FWSS_PROXY_ADDRESS" ]; then FWSS_INIT_COUNTER=$($SCRIPT_DIR/get-initialized-counter.sh $FWSS_PROXY_ADDRESS) else @@ -120,6 +140,8 @@ 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" \ + "value_accrual_router=$VALUE_ACCRUAL_ROUTER_ADDRESS" \ "filbeam_beneficiary=$FILBEAM_BENEFICIARY_ADDRESS" \ "service_provider_registry=$SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS" \ "session_key_registry=$SESSION_KEY_REGISTRY_ADDRESS" \ @@ -130,6 +152,7 @@ echo "" echo "# DEPLOYMENT COMPLETE" echo "SignatureVerificationLib: $SIGNATURE_VERIFICATION_LIB_ADDRESS" echo "Rails: $RAILS_LIB_ADDRESS" +echo "ValueAccrualRouter: $VALUE_ACCRUAL_ROUTER_ADDRESS" echo "FilecoinWarmStorageService Implementation: $FWSS_IMPLEMENTATION_ADDRESS" echo "" @@ -145,10 +168,10 @@ if [ "${AUTO_VERIFY:-true}" = "true" ]; then verify_contracts_batch \ "$SIGNATURE_VERIFICATION_LIB_ADDRESS,src/lib/SignatureVerificationLib.sol:SignatureVerificationLib" \ "$RAILS_LIB_ADDRESS,src/lib/Rails.sol:Rails" \ - "$FWSS_IMPLEMENTATION_ADDRESS,src/FilecoinWarmStorageService.sol:FilecoinWarmStorageService" + "$FWSS_IMPLEMENTATION_ADDRESS,src/FilecoinWarmStorageService.sol:FilecoinWarmStorageService" \ + "$VALUE_ACCRUAL_ROUTER_ADDRESS,src/ValueAccrualRouter.sol:ValueAccrualRouter" popd >/dev/null else echo echo "⏭️ Skipping automatic verification (export AUTO_VERIFY=true to enable)" fi - From 29e9886dd7248bb6ced1997bb88fb023d618c13f Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Thu, 11 Jun 2026 15:14:18 -0400 Subject: [PATCH 2/6] feat: price axlUSDC storage at a USD 5.00/TiB/month base Providers net USD 5 per TiB-month on USDC rails (double the USDFC list's USD 2.50 base); the posted price carries the same 1/(1 - 2%) NVAF gross-up, giving 5.102041 USDC/TiB/month. All other USDC amounts keep the USDFC-equivalent base. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++--- service_contracts/src/lib/PriceListUSDC.sol | 27 ++++++++++--------- .../test/MultiTokenValueAccrual.t.sol | 19 ++++++++----- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df61a7d..ad602482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,12 @@ new ValueAccrualRouter contract, sold for FIL by recurring Dutch auction, and bu - 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) with SP-bound amounts grossed up by 1/(1 − 2%) so the SP - nets the USDFC-equivalent after the NVAF; exposed via - `FilecoinWarmStorageServiceStateView.getPriceListUSDC()`. The per-dataset fee is set at the 6-decimal - per-epoch quantization floor (`0.0864 USDC/month`). +- `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 `ValueAccrualRouter` as `serviceFeeRecipient`. Owner-stageable for future data sets via `setUSDCCommissionBps` (capped at 200 bps — the cap diff --git a/service_contracts/src/lib/PriceListUSDC.sol b/service_contracts/src/lib/PriceListUSDC.sol index 4b4caef1..600cefaa 100644 --- a/service_contracts/src/lib/PriceListUSDC.sol +++ b/service_contracts/src/lib/PriceListUSDC.sol @@ -15,26 +15,29 @@ import { // Price list for USDC-denominated (bridged axlUSDC) data sets. // -// Two deliberate differences from the USDFC list: +// Three deliberate differences from the USDFC list: // -// 1. Gross-up. USDC rails carry a network value-accrual fee (NVAF) as the rail's operator +// 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, routed to the ValueAccrualRouter and burned. All SP-bound amounts below are -// grossed up by 1/(1 - 2%) — rounded up — so the SP nets the USDFC-equivalent amount after -// the commission; the customer bears the NVAF as a posted-price difference, and USDFC keeps -// a real discount. (The 0.5% Filecoin Pay network fee applies identically on both tokens, so -// it does not enter the gross-up.) +// 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.) // -// 2. Quantization floor. USDC has 6 decimals, and rails pay per epoch: any monthly amount below +// 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 -// (< ~35 GiB) still truncate toward zero; the dataset fee floor keeps every active data set -// paying a non-zero stream. +// 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 = 2_551_021; // 2.5 / 0.98, ceil +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; @@ -47,7 +50,7 @@ uint256 constant USDC_DEFAULT_CACHE_MISS_LOCKUP_AMOUNT = 306_123; // 0.3 / 0.98, // 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 -// USDFC-equivalent. Raising the NVAF beyond 2% requires a contract upgrade that also revises +// 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; diff --git a/service_contracts/test/MultiTokenValueAccrual.t.sol b/service_contracts/test/MultiTokenValueAccrual.t.sol index 2c0406cb..370ea1c7 100644 --- a/service_contracts/test/MultiTokenValueAccrual.t.sol +++ b/service_contracts/test/MultiTokenValueAccrual.t.sol @@ -33,7 +33,7 @@ import { USDC_STORAGE_PRICE_PER_TIB_PER_MONTH, USDC_TERMINATE_FEE } from "../src/lib/PriceListUSDC.sol"; -import {EPOCHS_PER_MONTH, PriceList} from "../src/lib/PriceList.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"; @@ -401,11 +401,16 @@ contract MultiTokenValueAccrualTest is MockFVMTest { FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); FilecoinPayV1.RailView memory pdpRail = payments.getRail(info.pdpRailId); - // floor(2_551_021 / 86_400) = 29 size-based + 1 dataset-fee floor = 30 microUSDC/epoch - uint256 expectedRate = - USDC_STORAGE_PRICE_PER_TIB_PER_MONTH / EPOCHS_PER_MONTH + USDC_DATASET_FEE_PER_MONTH / EPOCHS_PER_MONTH; + uint256 expectedRate = usdcRatePerEpochFor1TiB(); assertEq(pdpRail.paymentRate, expectedRate, "1 TiB USDC rate per epoch"); - assertEq(expectedRate, 30, "expected 30 microUSDC per epoch for 1 TiB"); + // 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 { @@ -458,8 +463,8 @@ contract MultiTokenValueAccrualTest is MockFVMTest { function testUSDCSettlementSkimsCommissionToRouter() public { (, uint256 settled, uint256 commission) = _settleProvenUSDCPeriod(); - // One full proven proving period at 30 microUSDC/epoch - assertEq(settled, 30 * 2880, "settled amount for one proven period"); + // 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"); // Router account holds the op-fee commission (asserted inside the helper) plus the From 2793a2ea889027390a5a081bc2e39cb7b4b2b2f4 Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Thu, 11 Jun 2026 15:21:50 -0400 Subject: [PATCH 3/6] fix: unpin mainnet RAILS_LIB_ADDRESS after Rails ABI change The v1.3.0 mainnet deploy (#523) pinned a Rails library built before this change. Rails' ABI changes here (createRails signature, new public functions), so reusing the pinned address would link the new implementation against selectors the deployed library does not have. Removing the pin makes the next deploy relink against a fresh Rails, matching the calibnet unpin and the new upgrade-checklist step. Co-Authored-By: Claude Fable 5 --- service_contracts/deployments.json | 1 - 1 file changed, 1 deletion(-) diff --git a/service_contracts/deployments.json b/service_contracts/deployments.json index 06078327..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", From 8f15aa3a782f857a0d508912892052c378ae5331 Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Thu, 11 Jun 2026 15:44:09 -0400 Subject: [PATCH 4/6] chore: satisfy forge fmt/lint under the CI toolchain (v1.3.5) - Reformat with forge 1.3.5 (CI pin); newer local forge formatted a few constructs differently and would fail the CI fmt check. - Drop unused re-exported constants from the PriceListUSDFC import (EPOCHS_PER_DAY moves consumers to PriceList.sol, GIB/MIB unused). - Use checked SafeERC20 transfers for the 6-decimal mock in tests. forge fmt --check and forge lint are clean under v1.3.5; 823 tests pass. Co-Authored-By: Claude Fable 5 --- .../src/FilecoinWarmStorageService.sol | 88 ++++++++----------- .../lib/FilecoinWarmStorageServiceLayout.sol | 1 - ...WarmStorageServiceStateInternalLibrary.sol | 12 +-- ...FilecoinWarmStorageServiceStateLibrary.sol | 12 +-- service_contracts/src/lib/PriceListUSDC.sol | 1 - service_contracts/src/lib/PriceListUSDFC.sol | 3 - service_contracts/src/lib/Rails.sol | 3 +- service_contracts/test/Abandonment.t.sol | 8 +- .../test/MultiTokenValueAccrual.t.sol | 5 +- service_contracts/test/mocks/SharedMocks.sol | 5 +- 10 files changed, 61 insertions(+), 77 deletions(-) diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index 97a548db..23460487 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -29,9 +29,7 @@ import { TOKEN_DECIMALS } from "./lib/PriceListUSDFC.sol"; import { - MAX_USDC_SERVICE_COMMISSION_BPS, - USDC_SERVICE_COMMISSION_BPS, - USDC_TOKEN_DECIMALS + 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"; @@ -746,10 +744,7 @@ contract FilecoinWarmStorageService is uint256 dataSetId, uint256, // deletedLeafCount, - not used bytes calldata // extraData, - not used - ) - external - onlyPDPVerifier - { + ) external onlyPDPVerifier { DataSetInfo storage info = dataSetInfo[dataSetId]; require(info.pdpRailId != 0, Errors.DataSetNotRegistered(dataSetId)); @@ -967,15 +962,14 @@ contract FilecoinWarmStorageService is (,,, uint256 schedulePieceRemovalsFee,,) = Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); uint96 newPending = info.pendingOneTimePayments + uint96(schedulePieceRemovalsFee); - info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress) - .replenishReserve( - info.pdpRailId, - info.pdpEndEpoch, - info.lifecycleReserveBalance, - newPending, - _paymentTokenOf(dataSetId), - usdcTokenAddress - ); + info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).replenishReserve( + info.pdpRailId, + info.pdpEndEpoch, + info.lifecycleReserveBalance, + newPending, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); info.pendingOneTimePayments = newPending; // Queue piece IDs for metadata cleanup at nextProvingPeriod @@ -992,10 +986,7 @@ contract FilecoinWarmStorageService is uint256, /*challengedLeafCount*/ uint256, /*seed*/ uint256 challengeCount - ) - external - onlyPDPVerifier - { + ) external onlyPDPVerifier { requirePaymentNotBeyondEndEpoch(dataSetId); if (provenThisPeriod[dataSetId]) { @@ -1137,11 +1128,7 @@ contract FilecoinWarmStorageService is address, // oldServiceProvider address, // newServiceProvider bytes calldata // extraData - not used - ) - external - override - onlyPDPVerifier - { + ) external override onlyPDPVerifier { revert Errors.StorageProviderChangesNotSupported(); } @@ -1233,8 +1220,9 @@ contract FilecoinWarmStorageService is // Check if CDN rails are configured (presence of rails indicates CDN was set up) require(info.cdnRailId != 0 && info.cacheMissRailId != 0, Errors.InvalidDataSetId(dataSetId)); - FilecoinPayV1(paymentsContractAddress) - .settleCDNRails(info.cdnRailId, info.cacheMissRailId, cdnAmount, cacheMissAmount); + FilecoinPayV1(paymentsContractAddress).settleCDNRails( + info.cdnRailId, info.cacheMissRailId, cdnAmount, cacheMissAmount + ); } /** @@ -1256,16 +1244,15 @@ contract FilecoinWarmStorageService is // Check if cache miss and CDN rails are configured require(info.cacheMissRailId != 0 && info.cdnRailId != 0, Errors.InvalidDataSetId(dataSetId)); - FilecoinPayV1(paymentsContractAddress) - .topUpCDNRails( - dataSetId, - info.cacheMissRailId, - info.cdnRailId, - cacheMissAmountToAdd, - cdnAmountToAdd, - _paymentTokenOf(dataSetId), - usdcTokenAddress - ); + FilecoinPayV1(paymentsContractAddress).topUpCDNRails( + dataSetId, + info.cacheMissRailId, + info.cdnRailId, + cacheMissAmountToAdd, + cdnAmountToAdd, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); } function terminateCDNService(uint256 dataSetId) external onlyFilBeamController { @@ -1326,18 +1313,17 @@ contract FilecoinWarmStorageService is uint256 pdpRailId = info.pdpRailId; require(pdpRailId != 0, Errors.NoPDPPaymentRail(dataSetId)); - info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress) - .updateStorageRates( - dataSetId, - pdpRailId, - leafCount, - pending, - reserveBalance, - info.pdpEndEpoch, - immediateTermination, - _paymentTokenOf(dataSetId), - usdcTokenAddress - ); + info.lifecycleReserveBalance = FilecoinPayV1(paymentsContractAddress).updateStorageRates( + dataSetId, + pdpRailId, + leafCount, + pending, + reserveBalance, + info.pdpEndEpoch, + immediateTermination, + _paymentTokenOf(dataSetId), + usdcTokenAddress + ); info.pendingOneTimePayments = 0; } @@ -1682,7 +1668,9 @@ contract FilecoinWarmStorageService is // If no epochs are proven, no payment is due (but settlement may still advance) if (provenEpochCount == 0) { return ValidationResult({ - modifiedAmount: 0, settleUpto: settleUpTo, note: "No proven epochs in the requested range" + modifiedAmount: 0, + settleUpto: settleUpTo, + note: "No proven epochs in the requested range" }); } diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol index daa08c4d..060969d2 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol @@ -30,4 +30,3 @@ bytes32 constant DEPRECATED_MINIMUM_STORAGE_RATE_PER_MONTH_SLOT = bytes32(uint25 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 1ec18007..8f8a879d 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateInternalLibrary.sol @@ -7,7 +7,9 @@ pragma solidity ^0.8.20; import {Cids} from "@pdp/Cids.sol"; import {Errors} from "../Errors.sol"; -import {CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService} from "../FilecoinWarmStorageService.sol"; +import { + CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService +} from "../FilecoinWarmStorageService.sol"; import { DATASET_FEE_PER_MONTH, SERVICE_COMMISSION_BPS, @@ -230,12 +232,12 @@ library FilecoinWarmStorageServiceStateInternalLibrary { returns (bool) { return uint256( - service.extsload( + service.extsload( keccak256( - abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) - ) + abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) + ) ) - ) & (1 << (periodId & 255)) != 0; + ) & (1 << (periodId & 255)) != 0; } function provingActivationEpoch(FilecoinWarmStorageService service, uint256 dataSetId) diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol index 06e92d14..3449f99b 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceStateLibrary.sol @@ -3,7 +3,9 @@ pragma solidity ^0.8.20; import {Cids} from "@pdp/Cids.sol"; import {Errors} from "../Errors.sol"; -import {CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService} from "../FilecoinWarmStorageService.sol"; +import { + CHALLENGES_PER_PROOF, NO_PROVING_DEADLINE, FilecoinWarmStorageService +} from "../FilecoinWarmStorageService.sol"; import { DATASET_FEE_PER_MONTH, SERVICE_COMMISSION_BPS, @@ -222,12 +224,12 @@ library FilecoinWarmStorageServiceStateLibrary { returns (bool) { return uint256( - service.extsload( + service.extsload( keccak256( - abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) - ) + abi.encode(periodId >> 8, keccak256(abi.encode(dataSetId, StorageLayout.PROVEN_PERIODS_SLOT))) + ) ) - ) & (1 << (periodId & 255)) != 0; + ) & (1 << (periodId & 255)) != 0; } function provingActivationEpoch(FilecoinWarmStorageService service, uint256 dataSetId) diff --git a/service_contracts/src/lib/PriceListUSDC.sol b/service_contracts/src/lib/PriceListUSDC.sol index 600cefaa..360e233b 100644 --- a/service_contracts/src/lib/PriceListUSDC.sol +++ b/service_contracts/src/lib/PriceListUSDC.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.20; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { - EPOCHS_PER_DAY, EPOCHS_PER_MONTH, CDN_LOCKUP_PERIOD, DEFAULT_LOCKUP_PERIOD, diff --git a/service_contracts/src/lib/PriceListUSDFC.sol b/service_contracts/src/lib/PriceListUSDFC.sol index db0ad6ff..5f8544e3 100644 --- a/service_contracts/src/lib/PriceListUSDFC.sol +++ b/service_contracts/src/lib/PriceListUSDFC.sol @@ -6,10 +6,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { CDN_LOCKUP_PERIOD, DEFAULT_LOCKUP_PERIOD, - EPOCHS_PER_DAY, EPOCHS_PER_MONTH, - GIB_IN_BYTES, - MIB_IN_BYTES, PriceList, PriceListFees, PriceListLockups, diff --git a/service_contracts/src/lib/Rails.sol b/service_contracts/src/lib/Rails.sol index 33f384b8..14fdab0d 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -424,8 +424,7 @@ library Rails { payments.modifyRailLockup(pdpRailId, 0, pending); newReserveBalance = 0; } else { - uint96 replenished = - replenishReserveIfNeeded(payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, pl); + 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 07a6c4f1..abebc77e 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"; diff --git a/service_contracts/test/MultiTokenValueAccrual.t.sol b/service_contracts/test/MultiTokenValueAccrual.t.sol index 370ea1c7..452e5c40 100644 --- a/service_contracts/test/MultiTokenValueAccrual.t.sol +++ b/service_contracts/test/MultiTokenValueAccrual.t.sol @@ -39,6 +39,7 @@ 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( @@ -141,7 +142,7 @@ contract MultiTokenValueAccrualTest is MockFVMTest { // Fund the client with both tokens and set up payments approvals + deposits mockUSDFC.safeTransfer(client, 10_000e18); - mockUSDC.transfer(client, 10_000e6); + mockUSDC.safeTransfer(client, 10_000e6); vm.startPrank(client); payments.setOperatorApproval(mockUSDFC, address(service), true, 1000e18, 1000e18, 365 days); @@ -782,7 +783,7 @@ contract MultiTokenValueAccrualTest is MockFVMTest { function testUSDCCreateRevertsWithExactRequiredLockup() public { address poorClient = address(0xf9); - mockUSDC.transfer(poorClient, 1e6); + mockUSDC.safeTransfer(poorClient, 1e6); vm.startPrank(poorClient); payments.setOperatorApproval(mockUSDC, address(service), true, 1000e6, 1000e6, 365 days); mockUSDC.approve(address(payments), 1e6); diff --git a/service_contracts/test/mocks/SharedMocks.sol b/service_contracts/test/mocks/SharedMocks.sol index 97b976d0..512a4aa9 100644 --- a/service_contracts/test/mocks/SharedMocks.sol +++ b/service_contracts/test/mocks/SharedMocks.sol @@ -230,8 +230,9 @@ contract MockPDPVerifier { // Call the listener's storageProviderChanged function if (listenerAddr != address(0)) { - PDPListener(listenerAddr) - .storageProviderChanged(dataSetId, oldServiceProvider, newServiceProvider, extraData); + PDPListener(listenerAddr).storageProviderChanged( + dataSetId, oldServiceProvider, newServiceProvider, extraData + ); } emit DataSetServiceProviderChanged(dataSetId, oldServiceProvider, newServiceProvider); From 91fe067491057818fc40e5d75d136834db136764 Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Fri, 24 Jul 2026 11:59:52 -0700 Subject: [PATCH 5/6] refactor: burn the NVAF through FilecoinPay's own fee auction Address wjmelements's review on #525: - Drop the ValueAccrualRouter. FilecoinPay already auctions and burns whatever accrues to its own account: setting a rail's serviceFeeRecipient to the FilecoinPay contract routes the operator commission into accounts[token][payments], the same pot burnForFees sells through the rolling Dutch auction. USDC rails now set serviceFeeRecipient = FilecoinPay; the router contract, its deploy-script steps, its errors, and the constructor wiring are gone. - Stop threading the USDC token address through the Rails library. The main contract resolves token -> price list once (an immutable compare) and passes a bool; priceListFor(bool) is now internal to the library. - Compare metadata strings directly instead of hashing. The paymentToken key and its USDC/USDFC values are length-checked and compared as fixed-size words, replacing the keccak256 comparisons. 819 tests pass (822 before: 5 router-specific tests replaced by 2 covering commission accrual to FilecoinPay and burnForFees buyout). Storage layout unchanged (25 slots); FWSS runtime size 23,809 bytes. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 24 +-- service_contracts/abi/Errors.abi.json | 43 ----- .../abi/FilecoinWarmStorageService.abi.json | 18 -- service_contracts/src/Errors.sol | 18 +- .../src/FilecoinWarmStorageService.sol | 42 ++-- service_contracts/src/ValueAccrualRouter.sol | 112 ----------- service_contracts/src/lib/PriceListUSDC.sol | 3 +- service_contracts/src/lib/Rails.sol | 80 ++++---- service_contracts/test/Abandonment.t.sol | 1 - .../test/FilecoinWarmStorageService.t.sol | 9 - .../FilecoinWarmStorageServiceOwner.t.sol | 1 - .../test/MultiTokenValueAccrual.t.sol | 179 +++++------------- .../test/ProviderValidation.t.sol | 1 - service_contracts/tools/UPGRADE-CHECKLIST.md | 4 +- .../tools/warm-storage-deploy-all.sh | 14 +- .../warm-storage-deploy-implementation.sh | 20 +- 16 files changed, 121 insertions(+), 448 deletions(-) delete mode 100644 service_contracts/src/ValueAccrualRouter.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index ad602482..aeced7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [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, routed to a -new ValueAccrualRouter contract, sold for FIL by recurring Dutch auction, and burned to the burn actor -(f099). USDFC data sets are unchanged and remain commission-free. +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 @@ -29,29 +29,25 @@ new ValueAccrualRouter contract, sold for FIL by recurring Dutch auction, and bu 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 `ValueAccrualRouter` as `serviceFeeRecipient`. + 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. -- `ValueAccrualRouter`: permissionless, ownerless terminal sink for the NVAF. Collects accrued - commission from FilecoinPay and sells it for native FIL through the same recurring Dutch-auction - mechanism as FilecoinPay's `burnForFees`; the buyer's FIL is burned via the burn actor. New - `CommissionCollected` and `CommissionBurned` events. - `PaymentTokenSelected` event at data set creation, and `FilecoinWarmStorageServiceStateView.getDataSetPaymentToken(dataSetId)` (pre-upgrade data sets resolve to USDFC). ### Changed -- `FilecoinWarmStorageService` constructor takes two new parameters after the USDFC token: the USDC - token address (zero address disables USDC data sets) and the `ValueAccrualRouter` address (required - when USDC is configured). +- `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 deploy the `ValueAccrualRouter` and pass the new constructor arguments; mainnet - defaults the USDC token to axlUSDC (`0xEB466342C4d449BC9f53A865D5Cb90586f405215`), calibration - defaults to disabled. +- 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 diff --git a/service_contracts/abi/Errors.abi.json b/service_contracts/abi/Errors.abi.json index 7d62a7c9..608aa3ce 100644 --- a/service_contracts/abi/Errors.abi.json +++ b/service_contracts/abi/Errors.abi.json @@ -148,22 +148,6 @@ } ] }, - { - "type": "error", - "name": "CommissionExceedsAvailable", - "inputs": [ - { - "name": "requested", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "available", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "CommissionExceedsMaximum", @@ -397,22 +381,6 @@ } ] }, - { - "type": "error", - "name": "InsufficientNativeTokenForBurn", - "inputs": [ - { - "name": "provided", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "required", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "InsufficientRateAllowance", @@ -678,17 +646,6 @@ } ] }, - { - "type": "error", - "name": "NativeBurnFailed", - "inputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "NextProvingPeriodAlreadyCalled", diff --git a/service_contracts/abi/FilecoinWarmStorageService.abi.json b/service_contracts/abi/FilecoinWarmStorageService.abi.json index db4f8884..3d924a71 100644 --- a/service_contracts/abi/FilecoinWarmStorageService.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageService.abi.json @@ -22,11 +22,6 @@ "type": "address", "internalType": "contract IERC20Metadata" }, - { - "name": "_valueAccrualRouter", - "type": "address", - "internalType": "address" - }, { "name": "_filBeamBeneficiaryAddress", "type": "address", @@ -956,19 +951,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "valueAccrualRouter", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "viewContractAddress", diff --git a/service_contracts/src/Errors.sol b/service_contracts/src/Errors.sol index fb4799ef..d328fd46 100644 --- a/service_contracts/src/Errors.sol +++ b/service_contracts/src/Errors.sol @@ -28,9 +28,7 @@ library Errors { /// FilBeam beneficiary address FilBeamBeneficiary, /// View contract address - View, - /// ValueAccrualRouter contract address - ValueAccrualRouter + View } /// @notice Enumerates the types of commission rates used in the protocol @@ -372,18 +370,4 @@ library Errors { /// pricing a 6-decimal token with the 18-decimal USDFC list. /// @param token The stored rail token error UnknownRailToken(address token); - - /// @notice Requested more accumulated commission than the router holds for this token - /// @param requested The requested token amount - /// @param available The token amount actually available - error CommissionExceedsAvailable(uint256 requested, uint256 available); - - /// @notice The native FIL sent does not meet the current auction price - /// @param provided The attoFIL provided - /// @param required The current auction price in attoFIL - error InsufficientNativeTokenForBurn(uint256 provided, uint256 required); - - /// @notice Burning native FIL via the burn actor failed - /// @param amount The attoFIL amount that failed to burn - error NativeBurnFailed(uint256 amount); } diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index 23460487..3a918d0a 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -128,7 +128,7 @@ contract FilecoinWarmStorageService is /// @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), - /// routed to the ValueAccrualRouter and burned. + /// 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. @@ -241,9 +241,6 @@ contract FilecoinWarmStorageService is IERC20Metadata public immutable usdfcTokenAddress; // Optional second rail token (bridged USDC, 6 decimals); zero address = USDC support disabled IERC20Metadata public immutable usdcTokenAddress; - // Receives the USDC-rail commission (NVAF) as the rails' serviceFeeRecipient; sells it for - // FIL by Dutch auction and burns the FIL. Required when usdcTokenAddress is set. - address public immutable valueAccrualRouter; address public immutable filBeamBeneficiaryAddress; ServiceProviderRegistry public immutable serviceProviderRegistry; SessionKeyRegistry public immutable sessionKeyRegistry; @@ -347,7 +344,6 @@ contract FilecoinWarmStorageService is address _paymentsContractAddress, IERC20Metadata _usdfc, IERC20Metadata _usdc, - address _valueAccrualRouter, address _filBeamBeneficiaryAddress, ServiceProviderRegistry _serviceProviderRegistry, SessionKeyRegistry _sessionKeyRegistry, @@ -368,12 +364,10 @@ contract FilecoinWarmStorageService is // 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))) { - require(_valueAccrualRouter != address(0), Errors.ZeroAddress(Errors.AddressField.ValueAccrualRouter)); // Verify token decimals from the USDC token contract require(USDC_TOKEN_DECIMALS == _usdc.decimals()); } usdcTokenAddress = _usdc; - valueAccrualRouter = _valueAccrualRouter; require(_filBeamBeneficiaryAddress != address(0), Errors.ZeroAddress(Errors.AddressField.FilBeamBeneficiary)); filBeamBeneficiaryAddress = _filBeamBeneficiaryAddress; @@ -670,14 +664,15 @@ contract FilecoinWarmStorageService is } // Resolve the rail token from the signed metadata (absent means USDFC). USDC rails carry - // the network value-accrual fee as their commission, routed to the ValueAccrualRouter. + // 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, - valueAccrualRouter, + paymentsContractAddress, address(this) ); dataSetPaymentToken[dataSetId] = railToken; @@ -707,7 +702,7 @@ contract FilecoinWarmStorageService is hasCDN ? filBeamBeneficiaryAddress : address(0), commissionBps, serviceFeeRecipient, - usdcTokenAddress + railToken == usdcTokenAddress ); railToDataSet[pdpRailId] = dataSetId; @@ -887,8 +882,7 @@ contract FilecoinWarmStorageService is // Verify the signature verifyAddPiecesSignature(payer, info.clientDataSetId, pieceData, nonce, metadataKeys, metadataValues, signature); - (, uint256 addPiecesBaseFee, uint256 addPiecesPerPieceFee,,,) = - Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); + (, uint256 addPiecesBaseFee, uint256 addPiecesPerPieceFee,,,) = Rails.oneTimeFees(_isUSDCDataSet(dataSetId)); uint96 pending = info.pendingOneTimePayments + uint96(addPiecesBaseFee + pieceData.length * addPiecesPerPieceFee); uint96 reserveBalance = info.lifecycleReserveBalance; @@ -960,15 +954,11 @@ contract FilecoinWarmStorageService is // Verify the signature verifySchedulePieceRemovalsSignature(payer, info.clientDataSetId, pieceIds, signature); - (,,, uint256 schedulePieceRemovalsFee,,) = Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); + 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, - _paymentTokenOf(dataSetId), - usdcTokenAddress + info.pdpRailId, info.pdpEndEpoch, info.lifecycleReserveBalance, newPending, usdcDataSet ); info.pendingOneTimePayments = newPending; @@ -1160,7 +1150,7 @@ contract FilecoinWarmStorageService is bytes memory signature = abi.decode(extraData, (bytes)); approver = _verifyTerminateServiceSignature(info.payer, dataSetId, signature); immediateTermination = true; - (,,,, uint256 terminateFee,) = Rails.oneTimeFees(_paymentTokenOf(dataSetId), usdcTokenAddress); + (,,,, uint256 terminateFee,) = Rails.oneTimeFees(_isUSDCDataSet(dataSetId)); info.pendingOneTimePayments += uint96(terminateFee); } else { require( @@ -1250,8 +1240,7 @@ contract FilecoinWarmStorageService is info.cdnRailId, cacheMissAmountToAdd, cdnAmountToAdd, - _paymentTokenOf(dataSetId), - usdcTokenAddress + _isUSDCDataSet(dataSetId) ); } @@ -1321,8 +1310,7 @@ contract FilecoinWarmStorageService is reserveBalance, info.pdpEndEpoch, immediateTermination, - _paymentTokenOf(dataSetId), - usdcTokenAddress + _isUSDCDataSet(dataSetId) ); info.pendingOneTimePayments = 0; } @@ -1342,6 +1330,12 @@ contract FilecoinWarmStorageService is } } + /// @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/ValueAccrualRouter.sol b/service_contracts/src/ValueAccrualRouter.sol deleted file mode 100644 index e0bbeb6b..00000000 --- a/service_contracts/src/ValueAccrualRouter.sol +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT -pragma solidity ^0.8.20; - -import {FVMPay} from "@fvm-solidity/FVMPay.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {Dutch} from "@fws-payments/Dutch.sol"; -import {FIRST_AUCTION_START_PRICE, MAX_AUCTION_START_PRICE, FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; -import {Errors} from "./Errors.sol"; - -/// @title ValueAccrualRouter -/// @notice Terminal sink for the network value-accrual fee (NVAF) charged on FWSS USDC rails. -/// -/// FWSS sets this contract as the `serviceFeeRecipient` of USDC rails, so the rails' operator -/// commission accrues to this contract's account inside FilecoinPay. The accumulated tokens are -/// sold for native FIL through a recurring Dutch auction — the same mechanism FilecoinPay itself -/// uses for its network fee (`burnForFees`) — and the FIL paid by the buyer is destroyed via the -/// burn actor (f099). Buyback-and-burn without any DEX or price-oracle dependency: the decaying -/// price lets arbitrageurs compete the auction down to market rate. -/// -/// Fully permissionless and immutable: no owner, no parameters to govern. Tokens sent here can -/// only ever leave through the auction; the FIL paid for them is always burned. -contract ValueAccrualRouter is ReentrancyGuard { - using Dutch for uint256; - using SafeERC20 for IERC20; - - FilecoinPayV1 public immutable payments; - - // pack into one storage slot (mirrors FilecoinPayV1's fee auction) - struct AuctionInfo { - uint88 startPrice; // highest possible price is MAX_AUCTION_START_PRICE - uint168 startTime; - } - - mapping(IERC20 token => AuctionInfo) public auctionInfo; - - event CommissionCollected(IERC20 indexed token, uint256 amount); - event CommissionBurned( - IERC20 indexed token, address indexed buyer, address indexed recipient, uint256 tokenAmount, uint256 filBurned - ); - - constructor(FilecoinPayV1 _payments) { - require(address(_payments) != address(0), Errors.ZeroAddress(Errors.AddressField.FilecoinPayV1)); - payments = _payments; - } - - /// @notice Pulls this contract's accrued commission for `token` out of FilecoinPay and arms - /// the auction if it isn't already running. Callable by anyone; also runs - /// automatically at the start of every `burnForCommission`. - /// @param token The commission token to collect - /// @return collected The amount pulled from FilecoinPay (0 if nothing had accrued) - function collect(IERC20 token) external nonReentrant returns (uint256 collected) { - return _collect(token); - } - - function _collect(IERC20 token) internal returns (uint256 collected) { - (collected,,,) = payments.accounts(token, address(this)); - if (collected > 0) { - payments.withdraw(token, collected); - emit CommissionCollected(token, collected); - } - - // (Re)arm the auction whenever there is stock to sell and no live price. Mirrors - // FilecoinPay's fee auction lifecycle: a fully-decayed auction resets to zero and is - // re-armed at the first price on the next accrual. - if (token.balanceOf(address(this)) > 0) { - AuctionInfo storage auction = auctionInfo[token]; - if (auction.startPrice == 0) { - auction.startPrice = FIRST_AUCTION_START_PRICE; - auction.startTime = uint168(block.timestamp); - } - } - } - - /// @notice Burn FIL to buy the accumulated commission tokens. - /// @dev The price is for the lot, independent of `requested` — rational buyers take - /// everything available (same semantics as FilecoinPay's `burnForFees`). The price - /// decays by 3/4 every week; each purchase resets it to 4x the clearing price. - /// @param token Which commission token to buy - /// @param recipient Receives the purchased tokens - /// @param requested Exact amount of tokens transferred - function burnForCommission(IERC20 token, address recipient, uint256 requested) external payable nonReentrant { - _collect(token); - - uint256 available = token.balanceOf(address(this)); - require(requested <= available, Errors.CommissionExceedsAvailable(requested, available)); - - AuctionInfo storage auction = auctionInfo[token]; - uint256 auctionPrice = uint256(auction.startPrice).decay(block.timestamp - auction.startTime); - require(msg.value >= auctionPrice, Errors.InsufficientNativeTokenForBurn(msg.value, auctionPrice)); - - auctionPrice *= Dutch.RESET_FACTOR; - if (auctionPrice > MAX_AUCTION_START_PRICE) { - auctionPrice = MAX_AUCTION_START_PRICE; - } - auction.startPrice = uint88(auctionPrice); - auction.startTime = uint168(block.timestamp); - - require(FVMPay.burn(msg.value), Errors.NativeBurnFailed(msg.value)); - - token.safeTransfer(recipient, requested); - - emit CommissionBurned(token, msg.sender, recipient, requested, msg.value); - } - - /// @notice Current auction price (attoFIL) to take the accumulated `token` commission. - function currentPrice(IERC20 token) external view returns (uint256) { - AuctionInfo storage auction = auctionInfo[token]; - return uint256(auction.startPrice).decay(block.timestamp - auction.startTime); - } -} diff --git a/service_contracts/src/lib/PriceListUSDC.sol b/service_contracts/src/lib/PriceListUSDC.sol index 360e233b..d6d35205 100644 --- a/service_contracts/src/lib/PriceListUSDC.sol +++ b/service_contracts/src/lib/PriceListUSDC.sol @@ -21,7 +21,8 @@ import { // the USDFC-equivalent base. // // 2. Gross-up. USDC rails carry a network value-accrual fee (NVAF) as the rail's operator -// commission, routed to the ValueAccrualRouter and burned. All SP-bound amounts below are +// 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.) diff --git a/service_contracts/src/lib/Rails.sol b/service_contracts/src/lib/Rails.sol index 14fdab0d..f1f1d3d6 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -8,13 +8,6 @@ import {EPOCHS_PER_MONTH, PriceList, storageRatePerEpoch} from "./PriceList.sol" import {SERVICE_COMMISSION_BPS, priceList as priceListUSDFC} from "./PriceListUSDFC.sol"; import {priceListUSDC} from "./PriceListUSDC.sol"; -// Metadata key selecting the rail token at data set creation; the value is signed by the payer -// along with the rest of the metadata. Absent means USDFC. -uint256 constant METADATA_KEY_PAYMENT_TOKEN_SIZE = 12; -bytes32 constant METADATA_KEY_PAYMENT_TOKEN_HASH = keccak256("paymentToken"); -bytes32 constant PAYMENT_TOKEN_VALUE_USDC_HASH = keccak256("USDC"); -bytes32 constant PAYMENT_TOKEN_VALUE_USDFC_HASH = keccak256("USDFC"); - event CDNPaymentRailsToppedUp( uint256 indexed dataSetId, uint256 cdnAmountAdded, @@ -32,31 +25,29 @@ 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 applying to `token` for this deployment. - /// @dev Token-keyed dispatch: the USDC list when `token` is the deployment's configured USDC - /// instance, the USDFC list otherwise. Lives here (external library) to keep the per-token - /// price constants out of the main contract's code size. `pl.token` is populated with - /// `token`. - function priceListFor(IERC20 token, IERC20 usdc) public pure returns (PriceList memory pl) { - if (address(usdc) != address(0) && token == usdc) { - pl = priceListUSDC(); - } else { - pl = priceListUSDFC(); - } - pl.token = token; + /// @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, routed to the ValueAccrualRouter. Any - /// other value reverts. + /// 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 valueAccrualRouter Receives USDC-rail commission (burned for FIL) + /// @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( @@ -65,23 +56,20 @@ library Rails { IERC20 usdfc, IERC20 usdc, uint256 usdcCommissionBps, - address valueAccrualRouter, + 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 == METADATA_KEY_PAYMENT_TOKEN_SIZE - && keccak256(keyBytes) == METADATA_KEY_PAYMENT_TOKEN_HASH - ) { - bytes32 valueHash = keccak256(bytes(metadataValues[i])); - if (valueHash == PAYMENT_TOKEN_VALUE_USDC_HASH) { + 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, valueAccrualRouter); + return (usdc, usdcCommissionBps, usdcFeeRecipient); } - if (valueHash != PAYMENT_TOKEN_VALUE_USDFC_HASH) { + if (valueBytes.length != 5 || bytes5(valueBytes) != "USDFC") { revert Errors.UnsupportedPaymentToken(metadataValues[i]); } break; // explicit "USDFC" selects the default @@ -90,9 +78,10 @@ library Rails { return (usdfc, SERVICE_COMMISSION_BPS, usdfcFeeRecipient); } - /// @notice The one-time fees and lifecycle reserve target for `token`, as flat words. + /// @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). - function oneTimeFees(IERC20 token, IERC20 usdc) + /// @param usdc Whether the data set's rail token is the deployment's USDC instance + function oneTimeFees(bool usdc) public pure returns ( @@ -104,7 +93,7 @@ library Rails { uint256 lifecycleReserveTarget ) { - PriceList memory pl = priceListFor(token, usdc); + PriceList memory pl = priceListFor(usdc); return ( pl.fees.createDataSetFee, pl.fees.addPiecesBaseFee, @@ -191,7 +180,7 @@ library Rails { address filBeamBeneficiaryAddress, uint256 commissionBps, address serviceFeeRecipient, - IERC20 usdc + bool usdc ) public returns ( @@ -202,7 +191,7 @@ library Rails { uint256 lifecycleReserveTarget ) { - PriceList memory pl = priceListFor(token, usdc); + PriceList memory pl = priceListFor(usdc); createDataSetFee = pl.fees.createDataSetFee; lifecycleReserveTarget = pl.lockups.lifecycleReserveTarget; bool hasCDN = filBeamBeneficiaryAddress != address(0); @@ -325,10 +314,9 @@ library Rails { uint256 cdnRailId, uint256 cacheMissAmountToAdd, uint256 cdnAmountToAdd, - IERC20 token, - IERC20 usdc + bool usdc ) public { - PriceList memory pl = priceListFor(token, usdc); + 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); @@ -390,19 +378,16 @@ library Rails { } /// @notice Public entry point for {replenishReserveIfNeeded}, resolving the price list from - /// the rail token. The internal variant stays inlined into in-library callers. + /// 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, - IERC20 token, - IERC20 usdc + bool usdc ) public returns (uint96) { - return replenishReserveIfNeeded( - payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, priceListFor(token, usdc) - ); + return replenishReserveIfNeeded(payments, pdpRailId, pdpEndEpoch, reserveBalance, pending, priceListFor(usdc)); } function updateStorageRates( @@ -414,10 +399,9 @@ library Rails { uint96 reserveBalance, uint256 pdpEndEpoch, bool immediateTermination, - IERC20 token, - IERC20 usdc + bool usdc ) public returns (uint96 newReserveBalance) { - PriceList memory pl = priceListFor(token, usdc); + PriceList memory pl = priceListFor(usdc); uint256 newStorageRatePerEpoch = storageRatePerEpoch(pl, leafCount); if (immediateTermination) { // No try/catch: immediateTermination implies the payer consented and is solvent. diff --git a/service_contracts/test/Abandonment.t.sol b/service_contracts/test/Abandonment.t.sol index abebc77e..9df943ed 100644 --- a/service_contracts/test/Abandonment.t.sol +++ b/service_contracts/test/Abandonment.t.sol @@ -99,7 +99,6 @@ contract AbandonmentTest is MockFVMTest { address(payments), usdfc, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/FilecoinWarmStorageService.t.sol b/service_contracts/test/FilecoinWarmStorageService.t.sol index e8107202..6234ee49 100644 --- a/service_contracts/test/FilecoinWarmStorageService.t.sol +++ b/service_contracts/test/FilecoinWarmStorageService.t.sol @@ -223,7 +223,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -310,7 +309,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -345,7 +343,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -370,7 +367,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -395,7 +391,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -430,7 +425,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -474,7 +468,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -515,7 +508,6 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -5517,7 +5509,6 @@ contract FilecoinWarmStorageServiceUpgradeTest is Test { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol b/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol index 2736b92d..1c9231dc 100644 --- a/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol +++ b/service_contracts/test/FilecoinWarmStorageServiceOwner.t.sol @@ -98,7 +98,6 @@ contract FilecoinWarmStorageServiceOwnerTest is MockFVMTest { address(payments), usdfcToken, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, providerRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/MultiTokenValueAccrual.t.sol b/service_contracts/test/MultiTokenValueAccrual.t.sol index 452e5c40..ac49bfe8 100644 --- a/service_contracts/test/MultiTokenValueAccrual.t.sol +++ b/service_contracts/test/MultiTokenValueAccrual.t.sol @@ -9,16 +9,20 @@ 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 {Dutch} from "@fws-payments/Dutch.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 {ValueAccrualRouter} from "../src/ValueAccrualRouter.sol"; import {Errors} from "../src/Errors.sol"; import {ServiceProviderRegistry} from "../src/ServiceProviderRegistry.sol"; import {ServiceProviderRegistryStorage} from "../src/ServiceProviderRegistryStorage.sol"; -import {LIFECYCLE_RESERVE_TARGET, calculateStorageSizeBasedRatePerEpoch} from "../src/lib/PriceListUSDFC.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, @@ -56,7 +60,6 @@ contract MultiTokenValueAccrualTest is MockFVMTest { FilecoinPayV1 public payments; MockERC20 public mockUSDFC; MockUSDC public mockUSDC; - ValueAccrualRouter public router; ServiceProviderRegistry public serviceProviderRegistry; SessionKeyRegistry public sessionKeyRegistry = new SessionKeyRegistry(); @@ -112,14 +115,12 @@ contract MultiTokenValueAccrualTest is MockFVMTest { ); payments = new FilecoinPayV1(); - router = new ValueAccrualRouter(payments); FilecoinWarmStorageService serviceImpl = new FilecoinWarmStorageService( address(mockPDPVerifier), address(payments), mockUSDFC, mockUSDC, - address(router), filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -213,7 +214,7 @@ contract MultiTokenValueAccrualTest is MockFVMTest { 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(router), "commission should route to the ValueAccrualRouter"); + 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"); @@ -234,12 +235,12 @@ contract MultiTokenValueAccrualTest is MockFVMTest { 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(router), "router 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(router), "router on CDN rail"); + assertEq(cdnRail.serviceFeeRecipient, address(payments), "FilecoinPay on CDN rail"); } function testUSDFCDataSetUnchangedByDefault() public { @@ -288,7 +289,6 @@ contract MultiTokenValueAccrualTest is MockFVMTest { address(payments), mockUSDFC, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -316,21 +316,6 @@ contract MultiTokenValueAccrualTest is MockFVMTest { // ==================== Constructor validation ==================== - function testConstructorRequiresRouterWhenUSDCConfigured() public { - vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, Errors.AddressField.ValueAccrualRouter)); - new FilecoinWarmStorageService( - address(mockPDPVerifier), - address(payments), - mockUSDFC, - mockUSDC, - address(0), // missing router - filBeamBeneficiary, - serviceProviderRegistry, - sessionKeyRegistry, - 4 - ); - } - function testConstructorRejectsWrongUSDCDecimals() public { MockERC20 eighteenDecimalToken = new MockERC20(); vm.expectRevert(); @@ -339,7 +324,6 @@ contract MultiTokenValueAccrualTest is MockFVMTest { address(payments), mockUSDFC, eighteenDecimalToken, // 18 decimals, must be 6 - address(router), filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, @@ -439,11 +423,11 @@ contract MultiTokenValueAccrualTest is MockFVMTest { FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); // One-time op fees (create + addPieces) were paid at piecesAdded time; their commission - // is already credited to the router. + // 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 opFeeCommission = commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS); - (uint256 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); - assertEq(routerFunds, opFeeCommission, "op-fee commission accrues to router at piecesAdded"); + 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; @@ -461,21 +445,21 @@ contract MultiTokenValueAccrualTest is MockFVMTest { return (dataSetId, totalSettledAmount, totalOperatorCommission); } - function testUSDCSettlementSkimsCommissionToRouter() public { + 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"); - // Router account holds the op-fee commission (asserted inside the helper) plus the - // streaming-settlement commission + // 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 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); + (uint256 feeFunds,,,) = payments.accounts(mockUSDC, address(payments)); assertEq( - routerFunds, - commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS) + commission, - "router account holds op-fee and settlement commission" + feeFunds, + networkFee(opFees) + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS) + networkFee(settled) + commission, + "FilecoinPay fee account holds op-fee and settlement accruals" ); } @@ -499,101 +483,37 @@ contract MultiTokenValueAccrualTest is MockFVMTest { assertEq(commission, 0, "USDFC rails carry no commission"); } - // ==================== ValueAccrualRouter ==================== + // ==================== Commission burn via FilecoinPay's fee auction ==================== - function testRouterCollectPullsCommission() public { + function testCommissionEntersFilecoinPayFeeAuction() public { _settleProvenUSDCPeriod(); - (uint256 accrued,,,) = payments.accounts(mockUSDC, address(router)); - assertGt(accrued, 0, "commission accrued in payments"); - - vm.expectEmit(true, false, false, true); - emit ValueAccrualRouter.CommissionCollected(mockUSDC, accrued); - uint256 collected = router.collect(mockUSDC); - - assertEq(collected, accrued, "collect returns the pulled amount"); - assertEq(mockUSDC.balanceOf(address(router)), accrued, "router holds the tokens"); - (uint256 remaining,,,) = payments.accounts(mockUSDC, address(router)); - assertEq(remaining, 0, "payments account drained"); + // 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,) = router.auctionInfo(mockUSDC); - assertEq(uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE), "auction armed at first price"); + (uint88 startPrice,) = payments.auctionInfo(mockUSDC); + assertEq(uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE), "fee auction armed at first price"); } - function testRouterBurnForCommissionBurnsFILAndPaysTokens() public { + function testBurnForFeesBuysAccruedCommissionAndBurnsFIL() public { _settleProvenUSDCPeriod(); - router.collect(mockUSDC); - uint256 available = mockUSDC.balanceOf(address(router)); + (uint256 available,,,) = payments.accounts(mockUSDC, address(payments)); address buyer = address(0xbeef); vm.deal(buyer, 1 ether); uint256 burnBalanceBefore = BURN_ADDRESS.balance; - // Underpaying the auction price reverts - vm.prank(buyer); - vm.expectRevert( - abi.encodeWithSelector( - Errors.InsufficientNativeTokenForBurn.selector, FIRST_AUCTION_START_PRICE - 1, FIRST_AUCTION_START_PRICE - ) - ); - router.burnForCommission{value: FIRST_AUCTION_START_PRICE - 1}(mockUSDC, buyer, available); - - // Requesting more than available reverts - vm.prank(buyer); - vm.expectRevert(abi.encodeWithSelector(Errors.CommissionExceedsAvailable.selector, available + 1, available)); - router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available + 1); - - // Paying the price takes the lot and burns the FIL + // Paying the auction price takes the accrual (network fee + NVAF) and burns the FIL vm.prank(buyer); - router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available); + payments.burnForFees{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, available); - assertEq(mockUSDC.balanceOf(buyer), available, "buyer receives the commission tokens"); - assertEq(mockUSDC.balanceOf(address(router)), 0, "router emptied"); + 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"); - - (uint88 startPrice,) = router.auctionInfo(mockUSDC); - assertEq( - uint256(startPrice), uint256(FIRST_AUCTION_START_PRICE) * Dutch.RESET_FACTOR, "auction price reset to 4x" - ); - } - - function testRouterBurnCollectsImplicitly() public { - // burnForCommission pulls pending commission from payments without a prior collect() - _settleProvenUSDCPeriod(); - (uint256 accrued,,,) = payments.accounts(mockUSDC, address(router)); - - address buyer = address(0xbeef); - vm.deal(buyer, 1 ether); - vm.prank(buyer); - router.burnForCommission{value: FIRST_AUCTION_START_PRICE}(mockUSDC, buyer, accrued); - assertEq(mockUSDC.balanceOf(buyer), accrued, "implicit collect during burn"); - } - - function testRouterAuctionPriceDecays() public { - _settleProvenUSDCPeriod(); - router.collect(mockUSDC); - - uint256 priceAtStart = router.currentPrice(mockUSDC); - assertEq(priceAtStart, FIRST_AUCTION_START_PRICE, "starts at first auction price"); - - vm.warp(block.timestamp + 3.5 days); - uint256 priceAfterHalving = router.currentPrice(mockUSDC); - assertEq(priceAfterHalving, FIRST_AUCTION_START_PRICE / 2, "halves per 3.5 days"); - - // A fully decayed auction clears at zero, and the next collect re-arms it - vm.warp(block.timestamp + 365 days); - assertEq(router.currentPrice(mockUSDC), 0, "fully decayed"); - - address buyer = address(0xbeef); - uint256 available = mockUSDC.balanceOf(address(router)); - vm.prank(buyer); - router.burnForCommission(mockUSDC, buyer, available); - assertEq(mockUSDC.balanceOf(buyer), available, "free claim after full decay"); - } - - function testRouterConstructorRejectsZeroPayments() public { - vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, Errors.AddressField.FilecoinPayV1)); - new ValueAccrualRouter(FilecoinPayV1(address(0))); } // ==================== Legacy data sets and token-drift guard ==================== @@ -620,8 +540,12 @@ contract MultiTokenValueAccrualTest is MockFVMTest { 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"); - (uint256 routerFunds,,,) = payments.accounts(mockUSDFC, address(router)); - assertEq(routerFunds, 0, "no commission accrues on legacy USDFC data sets"); + + // 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 { @@ -653,10 +577,10 @@ contract MultiTokenValueAccrualTest is MockFVMTest { service.terminateService(dataSetId, abi.encode(FAKE_SIGNATURE)); uint256 opFees = USDC_CREATE_DATA_SET_FEE + USDC_TERMINATE_FEE; - (uint256 routerFunds,,,) = payments.accounts(mockUSDC, address(router)); + (uint256 feeFunds,,,) = payments.accounts(mockUSDC, address(payments)); assertEq( - routerFunds, - commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS), + feeFunds, + networkFee(opFees) + commissionOn(opFees, USDC_SERVICE_COMMISSION_BPS), "NVAF skimmed from USDC create+terminate fees" ); @@ -728,7 +652,7 @@ contract MultiTokenValueAccrualTest is MockFVMTest { values[1] = "true"; uint256 dataSetId = createDataSet(keys, values); - (uint256 routerBefore,,,) = payments.accounts(mockUSDC, address(router)); + (uint256 feeBefore,,,) = payments.accounts(mockUSDC, address(payments)); (uint256 filBeamBefore,,,) = payments.accounts(mockUSDC, filBeamBeneficiary); uint256 cdnAmount = 400_000; // microUSDC, within the CDN lockup @@ -736,12 +660,12 @@ contract MultiTokenValueAccrualTest is MockFVMTest { vm.prank(filBeamController); service.settleFilBeamPaymentRails(dataSetId, cdnAmount, cacheMissAmount); - (uint256 routerAfter,,,) = payments.accounts(mockUSDC, address(router)); + (uint256 feeAfter,,,) = payments.accounts(mockUSDC, address(payments)); assertEq( - routerAfter - routerBefore, - commissionOn(cdnAmount, USDC_SERVICE_COMMISSION_BPS) + feeAfter - feeBefore, + networkFee(cdnAmount) + commissionOn(cdnAmount, USDC_SERVICE_COMMISSION_BPS) + networkFee(cacheMissAmount) + commissionOn(cacheMissAmount, USDC_SERVICE_COMMISSION_BPS), - "NVAF skimmed from both FilBeam settlements" + "network fee + NVAF skimmed from both FilBeam settlements" ); (uint256 filBeamAfter,,,) = payments.accounts(mockUSDC, filBeamBeneficiary); @@ -817,7 +741,6 @@ contract MultiTokenValueAccrualTest is MockFVMTest { address(payments), mockUSDFC, mockUSDC, - address(router), filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/test/ProviderValidation.t.sol b/service_contracts/test/ProviderValidation.t.sol index 2f52eba9..e48eb742 100644 --- a/service_contracts/test/ProviderValidation.t.sol +++ b/service_contracts/test/ProviderValidation.t.sol @@ -75,7 +75,6 @@ contract ProviderValidationTest is MockFVMTest { address(payments), usdfc, MockERC20(address(0)), // USDC disabled - address(0), // no ValueAccrualRouter filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry, diff --git a/service_contracts/tools/UPGRADE-CHECKLIST.md b/service_contracts/tools/UPGRADE-CHECKLIST.md index ef3cb461..864cfcb3 100644 --- a/service_contracts/tools/UPGRADE-CHECKLIST.md +++ b/service_contracts/tools/UPGRADE-CHECKLIST.md @@ -303,8 +303,8 @@ Use the deploy dry-run output to identify contracts that are `Up to date` versus -**Linked libraries (SignatureVerificationLib, Rails, ValueAccrualRouter)** -- [ ] If the release changes the source of any externally linked library (`src/lib/SignatureVerificationLib.sol`, `src/lib/Rails.sol`) or a companion contract the implementation depends on (`src/ValueAccrualRouter.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. +**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` diff --git a/service_contracts/tools/warm-storage-deploy-all.sh b/service_contracts/tools/warm-storage-deploy-all.sh index 83a4b8d4..4f514b5e 100755 --- a/service_contracts/tools/warm-storage-deploy-all.sh +++ b/service_contracts/tools/warm-storage-deploy-all.sh @@ -381,19 +381,10 @@ deploy_implementation_if_needed \ "src/lib/Rails.sol:Rails" \ "Rails" -# Step 7b: Deploy or use existing ValueAccrualRouter (only when USDC is enabled). -# Receives the USDC-rail commission, sells it for FIL via Dutch auction, burns the FIL. +# 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}" -if [ "$USDC_TOKEN_ADDRESS" != "$ZERO_ADDRESS" ]; then - deploy_implementation_if_needed \ - "VALUE_ACCRUAL_ROUTER_ADDRESS" \ - "src/ValueAccrualRouter.sol:ValueAccrualRouter" \ - "ValueAccrualRouter" \ - "$FILECOIN_PAY_ADDRESS" -else - VALUE_ACCRUAL_ROUTER_ADDRESS="$ZERO_ADDRESS" -fi # Step 8: Deploy or use existing FilecoinWarmStorageService implementation # Set LIBRARIES variable for the deployment helper (comma-separated path:name:address) @@ -411,7 +402,6 @@ deploy_implementation_if_needed \ "filecoin_pay=$FILECOIN_PAY_ADDRESS" \ "usdfc_token=$USDFC_TOKEN_ADDRESS" \ "usdc_token=$USDC_TOKEN_ADDRESS" \ - "value_accrual_router=$VALUE_ACCRUAL_ROUTER_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 538e10e1..db3084b2 100755 --- a/service_contracts/tools/warm-storage-deploy-implementation.sh +++ b/service_contracts/tools/warm-storage-deploy-implementation.sh @@ -71,7 +71,8 @@ if [ -z "$SESSION_KEY_REGISTRY_ADDRESS" ]; then fi # Set network-specific token addresses based on chain ID. The bridged USDC (6 decimals) is -# optional: the zero address disables USDC data sets. +# 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") @@ -115,18 +116,6 @@ deploy_implementation_if_needed \ "src/lib/Rails.sol:Rails" \ "Rails" -# The ValueAccrualRouter receives the USDC-rail commission (network value-accrual fee), sells -# it for FIL via Dutch auction, and burns the FIL. Required whenever USDC is enabled. -if [ "$USDC_TOKEN_ADDRESS" != "$ZERO_ADDRESS" ]; then - deploy_implementation_if_needed \ - "VALUE_ACCRUAL_ROUTER_ADDRESS" \ - "src/ValueAccrualRouter.sol:ValueAccrualRouter" \ - "ValueAccrualRouter" \ - "filecoin_pay=$FILECOIN_PAY_ADDRESS" -else - VALUE_ACCRUAL_ROUTER_ADDRESS="$ZERO_ADDRESS" -fi - if [ -n "$FWSS_PROXY_ADDRESS" ]; then FWSS_INIT_COUNTER=$($SCRIPT_DIR/get-initialized-counter.sh $FWSS_PROXY_ADDRESS) else @@ -141,7 +130,6 @@ deploy_implementation_if_needed \ "filecoin_pay=$FILECOIN_PAY_ADDRESS" \ "usdfc_token=$USDFC_TOKEN_ADDRESS" \ "usdc_token=$USDC_TOKEN_ADDRESS" \ - "value_accrual_router=$VALUE_ACCRUAL_ROUTER_ADDRESS" \ "filbeam_beneficiary=$FILBEAM_BENEFICIARY_ADDRESS" \ "service_provider_registry=$SERVICE_PROVIDER_REGISTRY_PROXY_ADDRESS" \ "session_key_registry=$SESSION_KEY_REGISTRY_ADDRESS" \ @@ -152,7 +140,6 @@ echo "" echo "# DEPLOYMENT COMPLETE" echo "SignatureVerificationLib: $SIGNATURE_VERIFICATION_LIB_ADDRESS" echo "Rails: $RAILS_LIB_ADDRESS" -echo "ValueAccrualRouter: $VALUE_ACCRUAL_ROUTER_ADDRESS" echo "FilecoinWarmStorageService Implementation: $FWSS_IMPLEMENTATION_ADDRESS" echo "" @@ -168,8 +155,7 @@ if [ "${AUTO_VERIFY:-true}" = "true" ]; then verify_contracts_batch \ "$SIGNATURE_VERIFICATION_LIB_ADDRESS,src/lib/SignatureVerificationLib.sol:SignatureVerificationLib" \ "$RAILS_LIB_ADDRESS,src/lib/Rails.sol:Rails" \ - "$FWSS_IMPLEMENTATION_ADDRESS,src/FilecoinWarmStorageService.sol:FilecoinWarmStorageService" \ - "$VALUE_ACCRUAL_ROUTER_ADDRESS,src/ValueAccrualRouter.sol:ValueAccrualRouter" + "$FWSS_IMPLEMENTATION_ADDRESS,src/FilecoinWarmStorageService.sol:FilecoinWarmStorageService" popd >/dev/null else echo From dc02b43d0c2bcf4a30ca21420ab51a4413fb2c07 Mon Sep 17 00:00:00 2001 From: Hannah Howard Date: Fri, 24 Jul 2026 12:55:22 -0700 Subject: [PATCH 6/6] fix: integrate with main's abandonment and upgrade-announcement changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two build fixes from rebasing onto main: abandonRails (underfunded-payer handling, #520) uses DEFAULT_LOCKUP_PERIOD, which this branch moved from PriceListUSDFC to PriceList — import it from its new home; and the new announce-upgrade-only-owner test constructs FWSS, which now takes the optional USDC token parameter. Co-Authored-By: Claude Fable 5 --- service_contracts/src/lib/Rails.sol | 2 +- service_contracts/test/FilecoinWarmStorageService.t.sol | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/service_contracts/src/lib/Rails.sol b/service_contracts/src/lib/Rails.sol index f1f1d3d6..d7fca697 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -4,7 +4,7 @@ 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 {EPOCHS_PER_MONTH, PriceList, storageRatePerEpoch} from "./PriceList.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"; diff --git a/service_contracts/test/FilecoinWarmStorageService.t.sol b/service_contracts/test/FilecoinWarmStorageService.t.sol index 6234ee49..1f6e3821 100644 --- a/service_contracts/test/FilecoinWarmStorageService.t.sol +++ b/service_contracts/test/FilecoinWarmStorageService.t.sol @@ -578,6 +578,7 @@ contract FilecoinWarmStorageServiceTest is MockFVMTest { address(mockPDPVerifier), address(payments), mockUSDFC, + MockERC20(address(0)), // USDC disabled filBeamBeneficiary, serviceProviderRegistry, sessionKeyRegistry,