diff --git a/SPEC.md b/SPEC.md index fc20c621..807df09a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -257,6 +257,12 @@ Datasets with CDN support have three payment rails: a **PDP rail** for storage p Both CDN rails have `paymentRate = 0` and use fixed lockup for one-time payments based on usage. At dataset creation the cache-miss rail is seeded with **0.3 USDFC** and the CDN rail with **0.7 USDFC**. Both CDN rails use a **5-day lockup period**, which sets the settle window FilBeam has after dataset deletion to claim any remaining fixed lockup. +### Shared bandwidth rail (CDN subscriptions) + +The bandwidth rail can be shared across multiple data sets of the same payer so CDN is bought once even when a piece is stored in several data sets (for example multi-copy upload across providers). The `withCDN` metadata value carries an optional group id, and FWSS keys a shared bandwidth rail by `keccak256(abi.encode(payer, groupId))`. When a data set is created with a group id whose shared bandwidth rail already exists and is active, the data set joins it, no second bandwidth rail or 0.7 USDFC lockup is created. The shared `cdnRailId` is the subscription identity: every member data set resolves to the same rail, so the FilBeam controller meters and settles bandwidth once per rail via `settleCDNBandwidthRail(cdnRailId, cdnAmount)`. + +The cache-miss rail stays per data set, its payee is the data set's SP, which differs per copy. `cdnRailRefCount` counts the data sets referencing each shared bandwidth rail, and the rail is terminated only when the last member is torn down (via `terminateCDNService`, `dataSetDeleted`, or abandonment). An empty group id keeps the legacy one-rail-per-data-set behavior. + ### Payment Models PDP and CDN rails use fundamentally different payment models: diff --git a/service_contracts/abi/Errors.abi.json b/service_contracts/abi/Errors.abi.json index b67dd030..5efa09dd 100644 --- a/service_contracts/abi/Errors.abi.json +++ b/service_contracts/abi/Errors.abi.json @@ -940,6 +940,17 @@ } ] }, + { + "type": "error", + "name": "UnknownCDNBandwidthRail", + "inputs": [ + { + "name": "cdnRailId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "UnsupportedSignatureV", diff --git a/service_contracts/abi/FilecoinWarmStorageService.abi.json b/service_contracts/abi/FilecoinWarmStorageService.abi.json index fc878062..8056c095 100644 --- a/service_contracts/abi/FilecoinWarmStorageService.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageService.abi.json @@ -653,6 +653,24 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "settleCDNBandwidthRail", + "inputs": [ + { + "name": "cdnRailId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cdnAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "settleFilBeamPaymentRails", @@ -2081,6 +2099,17 @@ } ] }, + { + "type": "error", + "name": "UnknownCDNBandwidthRail", + "inputs": [ + { + "name": "cdnRailId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "ZeroAddress", diff --git a/service_contracts/src/Errors.sol b/service_contracts/src/Errors.sol index 734e5f51..609d5d40 100644 --- a/service_contracts/src/Errors.sol +++ b/service_contracts/src/Errors.sol @@ -289,6 +289,10 @@ library Errors { /// @param dataSetId The data set ID error FilBeamServiceNotConfigured(uint256 dataSetId); + /// @notice The rail id is not a CDN bandwidth rail managed by this contract + /// @param cdnRailId The CDN bandwidth rail ID + error UnknownCDNBandwidthRail(uint256 cdnRailId); + /// @notice Only the FilBeam controller address can call this function /// @param expected The expected FilBeam controller address /// @param actual The caller address diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index 2bb3a7f8..f8c65f26 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -303,6 +303,14 @@ contract FilecoinWarmStorageService is // Piece IDs awaiting metadata cleanup; cleared each nextProvingPeriod call mapping(uint256 dataSetId => uint256[] pieceIds) internal scheduledPieceMetadataRemovals; + // Shared CDN bandwidth rail per (payer, CDN group). The shared rail id is the CDN subscription + // identity: every data set in the group resolves to the same cdnRailId. Keyed by + // keccak256(abi.encode(payer, groupId)), where groupId is the value of the `withCDN` metadata + // entry. An empty group id leaves this unused and the data set keeps a bandwidth rail of its own. + mapping(bytes32 cdnGroupKey => uint256 cdnRailId) internal cdnGroupRail; + // Number of data sets referencing each shared CDN bandwidth rail; the rail is torn down at zero. + mapping(uint256 cdnRailId => uint256 refCount) internal cdnRailRefCount; + event UpgradeAnnounced(PlannedUpgrade plannedUpgrade); // ========================================================================= @@ -599,11 +607,21 @@ contract FilecoinWarmStorageService is // Create the payment rails using the FilecoinPayV1 contract FilecoinPayV1 payments = FilecoinPayV1(paymentsContractAddress); - // Determine once whether CDN is enabled in metadata and reuse the result - bool hasCDN = hasCDNMetadataKey(createData.metadataKeys); + // Determine once whether CDN is enabled and, if so, which shared subscription it joins. + // The group key is derived from the payer and the `withCDN` metadata value, an empty value + // means the data set is its own subscription (legacy one-rail-per-data-set behavior). + (bool hasCDN, bytes32 cdnGroupKey) = + cdnMetadata(createData.payer, createData.metadataKeys, createData.metadataValues); (uint256 pdpRailId, uint256 cacheMissRailId, uint256 cdnRailId) = payments.createRails( - dataSetId, usdfcTokenAddress, createData.payer, payee, hasCDN ? filBeamBeneficiaryAddress : address(0) + dataSetId, + usdfcTokenAddress, + createData.payer, + payee, + hasCDN ? filBeamBeneficiaryAddress : address(0), + cdnGroupKey, + cdnGroupRail, + cdnRailRefCount ); railToDataSet[pdpRailId] = dataSetId; @@ -654,9 +672,14 @@ contract FilecoinWarmStorageService is // Abandonment path: rail was never terminated via terminateService. // SP forfeits pending op-fees; lifecycle reserve returns to the payer. _verifyInactivity(dataSetId); - // abandonRails also terminates CDN rails and clears the proving activation epoch + // abandonRails also terminates CDN rails and clears the proving activation epoch. + // The bandwidth rail is only torn down when this is its last referencing data set. payments.abandonRails( - provingActivationEpoch, dataSetId, info.pdpRailId, info.cacheMissRailId, info.cdnRailId + provingActivationEpoch, + dataSetId, + info.pdpRailId, + info.cacheMissRailId, + _bandwidthRailToTeardown(info.cdnRailId) ); } else { // Normal path: terminateService was already called. @@ -1094,6 +1117,22 @@ contract FilecoinWarmStorageService is ); } + /** + * @notice Settles a shared CDN bandwidth rail once for its whole subscription. + * @dev Only callable by the FilBeam controller. The shared bandwidth rail id is the CDN + * subscription identity, so a single call covers every data set in the group. Cache-miss is + * still settled per data set via `settleFilBeamPaymentRails` (with `cdnAmount == 0` for + * grouped data sets, so the bandwidth portion is only ever settled through this path). + * @param cdnRailId The shared CDN bandwidth rail id + * @param cdnAmount Amount to settle for the bandwidth rail + */ + function settleCDNBandwidthRail(uint256 cdnRailId, uint256 cdnAmount) external onlyFilBeamController { + require(cdnRailRefCount[cdnRailId] != 0, Errors.UnknownCDNBandwidthRail(cdnRailId)); + if (cdnAmount > 0) { + FilecoinPayV1(paymentsContractAddress).modifyRailPayment(cdnRailId, 0, cdnAmount); + } + } + /** * @notice Allows users to add funds to their CDN-related payment rails * @param dataSetId The ID of the data set @@ -1162,7 +1201,23 @@ contract FilecoinWarmStorageService is /// Ideally we would catch only specific error types, but contract size constraint prevents /// us from implementing error handling. function _terminateCDNRails(uint256 dataSetId, DataSetInfo storage info, FilecoinPayV1 payments) internal { - payments.terminateCDNRails(dataSetId, info.cacheMissRailId, info.cdnRailId); + payments.terminateCDNRails(dataSetId, info.cacheMissRailId, _bandwidthRailToTeardown(info.cdnRailId)); + } + + /// @notice Decrements the reference count for a shared CDN bandwidth rail. + /// @dev Returns the rail id to tear down (only when this was the last reference), or 0 when the + /// rail is still shared by sibling data sets and must stay alive. + function _bandwidthRailToTeardown(uint256 cdnRailId) internal returns (uint256) { + if (cdnRailId == 0) { + return 0; + } + uint256 refs = cdnRailRefCount[cdnRailId]; + if (refs <= 1) { + cdnRailRefCount[cdnRailId] = 0; + return cdnRailId; + } + cdnRailRefCount[cdnRailId] = refs - 1; + return 0; } function updatePaymentRates( @@ -1294,6 +1349,30 @@ contract FilecoinWarmStorageService is return false; } + /// @notice Reads the `withCDN` metadata entry, returning whether CDN is enabled and the CDN + /// subscription key the data set joins. + /// @dev The subscription key is keccak256(abi.encode(payer, groupId)) where groupId is the value + /// of the `withCDN` entry. An empty value yields a zero key, meaning the data set is its own + /// subscription. Keying by payer guarantees the shared rail's `from` matches every member, + /// so different payers can never share a rail. `keys` and `values` are equal length here, + /// validated by the caller before this is reached. + function cdnMetadata(address payer, string[] memory keys, string[] memory values) + internal + pure + returns (bool hasCDN, bytes32 cdnGroupKey) + { + for (uint256 i = 0; i < keys.length; i++) { + bytes memory keyBytes = bytes(keys[i]); + if (keyBytes.length == METADATA_KEY_WITH_CDN_SIZE && keccak256(keyBytes) == METADATA_KEY_WITH_CDN_HASH) { + hasCDN = true; + if (bytes(values[i]).length != 0) { + cdnGroupKey = keccak256(abi.encode(payer, values[i])); + } + break; + } + } + } + /** * @notice Returns true if key `withCDN` exists in the metadata keys of the data set. * @param dataSetId The sequential data set identifier diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json index 89f94451..9cb9fb84 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.json @@ -620,5 +620,47 @@ } } } + }, + { + "label": "cdnGroupRail", + "slot": "23", + "offset": 0, + "type": "mapping(bytes32 => uint256)", + "typeDetails": { + "label": "mapping(bytes32 => uint256)", + "encoding": "mapping", + "numberOfBytes": "32", + "key": { + "label": "bytes32", + "encoding": "inplace", + "numberOfBytes": "32" + }, + "value": { + "label": "uint256", + "encoding": "inplace", + "numberOfBytes": "32" + } + } + }, + { + "label": "cdnRailRefCount", + "slot": "24", + "offset": 0, + "type": "mapping(uint256 => uint256)", + "typeDetails": { + "label": "mapping(uint256 => uint256)", + "encoding": "mapping", + "numberOfBytes": "32", + "key": { + "label": "uint256", + "encoding": "inplace", + "numberOfBytes": "32" + }, + "value": { + "label": "uint256", + "encoding": "inplace", + "numberOfBytes": "32" + } + } } ] diff --git a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol index b0ae7aa0..ed7e9508 100644 --- a/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol +++ b/service_contracts/src/lib/FilecoinWarmStorageServiceLayout.sol @@ -28,3 +28,5 @@ bytes32 constant NEXT_UPGRADE_SLOT = bytes32(uint256(19)); bytes32 constant DEPRECATED_STORAGE_PRICE_PER_TIB_PER_MONTH_SLOT = bytes32(uint256(20)); bytes32 constant DEPRECATED_MINIMUM_STORAGE_RATE_PER_MONTH_SLOT = bytes32(uint256(21)); bytes32 constant SCHEDULED_PIECE_METADATA_REMOVALS_SLOT = bytes32(uint256(22)); +bytes32 constant CDN_GROUP_RAIL_SLOT = bytes32(uint256(23)); +bytes32 constant CDN_RAIL_REF_COUNT_SLOT = bytes32(uint256(24)); diff --git a/service_contracts/src/lib/Rails.sol b/service_contracts/src/lib/Rails.sol index cd512250..7aae1adb 100644 --- a/service_contracts/src/lib/Rails.sol +++ b/service_contracts/src/lib/Rails.sol @@ -30,6 +30,8 @@ event CDNServiceTerminated( address indexed caller, uint256 indexed dataSetId, uint256 cacheMissRailId, uint256 cdnRailId ); +event CDNSubscriptionJoined(uint256 indexed dataSetId, uint256 indexed cdnRailId, uint256 cacheMissRailId); + event DataSetAbandoned(uint256 indexed dataSetId, uint256 pdpRailId, uint256 cacheMissRailId, uint256 cdnRailId); event RailRateUpdated(uint256 indexed dataSetId, uint256 railId, uint256 newRate); @@ -42,12 +44,15 @@ library Rails { /// @param payments The FilecoinPayV1 contract instance /// @param usdfcTokenAddress The USDFC 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 includeCacheMiss Whether to include the fixed cache-miss lockup in the requirement checks + /// @param includeBandwidth Whether to include the fixed CDN bandwidth lockup. False when the data set + /// joins an existing shared bandwidth rail, the bandwidth lockup was paid by the first member. function validatePayerOperatorApprovalAndFunds( FilecoinPayV1 payments, IERC20 usdfcTokenAddress, address payer, - bool includeCDN + bool includeCacheMiss, + bool includeBandwidth ) 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 @@ -56,9 +61,13 @@ library Rails { uint256 requiredLockup = (DATASET_FEE_PER_MONTH * DEFAULT_LOCKUP_PERIOD) / EPOCHS_PER_MONTH + LIFECYCLE_RESERVE_TARGET; - // 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; + // The cache-miss rail is always per data set (its payee is this data set's SP). + if (includeCacheMiss) { + requiredLockup += DEFAULT_CACHE_MISS_LOCKUP_AMOUNT; + } + // The bandwidth rail is shared across a subscription, only its first member locks it. + if (includeBandwidth) { + requiredLockup += DEFAULT_CDN_LOCKUP_AMOUNT; } // Check that payer has sufficient available funds @@ -99,18 +108,40 @@ library Rails { ); } + /// @notice Creates the PDP rail and, when CDN is enabled, a per-data-set cache-miss rail plus a + /// CDN bandwidth rail. + /// @dev The bandwidth rail (payer -> FilBeam beneficiary) is shared across a CDN subscription: + /// when `cdnGroupKey` is non-zero and an active rail already exists for that key, the new + /// data set joins it instead of creating (and paying for) a second one. The cache-miss rail + /// is always per data set because its payee is this data set's SP, which differs per copy. + /// `cdnGroupRail` maps a subscription key to its shared bandwidth rail, `cdnRailRefCount` + /// counts the data sets referencing each bandwidth rail so it is torn down only at zero. function createRails( FilecoinPayV1 payments, uint256 dataSetId, IERC20 usdfcTokenAddress, address payer, address payee, - address filBeamBeneficiaryAddress + address filBeamBeneficiaryAddress, + bytes32 cdnGroupKey, + mapping(bytes32 cdnGroupKey => uint256 cdnRailId) storage cdnGroupRail, + mapping(uint256 cdnRailId => uint256 refCount) storage cdnRailRefCount ) public returns (uint256 pdpRailId, uint256 cacheMissRailId, uint256 cdnRailId) { 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); + + // Resolve whether an active shared bandwidth rail can be reused before validating funds, + // so a joiner is not asked to lock the bandwidth amount again. + uint256 sharedCdnRailId = 0; + if (hasCDN && cdnGroupKey != bytes32(0)) { + uint256 existing = cdnGroupRail[cdnGroupKey]; + if (existing != 0 && _railIsActive(payments, existing)) { + sharedCdnRailId = existing; + } + } + bool createBandwidthRail = hasCDN && sharedCdnRailId == 0; + + // Validate payer has sufficient funds and operator approvals to cover the required lockup. + validatePayerOperatorApprovalAndFunds(payments, usdfcTokenAddress, payer, hasCDN, createBandwidthRail); pdpRailId = payments.createRail( usdfcTokenAddress, // token address @@ -138,31 +169,59 @@ library Rails { ); payments.modifyRailLockup(cacheMissRailId, CDN_LOCKUP_PERIOD, DEFAULT_CACHE_MISS_LOCKUP_AMOUNT); - cdnRailId = payments.createRail( - usdfcTokenAddress, // token address - payer, // from (payer) - filBeamBeneficiaryAddress, // to FilBeam beneficiary - address(0), // no validator - 0, // no service commission - address(this) // controller - ); - payments.modifyRailLockup(cdnRailId, CDN_LOCKUP_PERIOD, DEFAULT_CDN_LOCKUP_AMOUNT); - - emit CDNPaymentRailsToppedUp( - dataSetId, - DEFAULT_CDN_LOCKUP_AMOUNT, - DEFAULT_CDN_LOCKUP_AMOUNT, - DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, - DEFAULT_CACHE_MISS_LOCKUP_AMOUNT - ); + if (createBandwidthRail) { + cdnRailId = payments.createRail( + usdfcTokenAddress, // token address + payer, // from (payer) + filBeamBeneficiaryAddress, // to FilBeam beneficiary + address(0), // no validator + 0, // no service commission + address(this) // controller + ); + payments.modifyRailLockup(cdnRailId, CDN_LOCKUP_PERIOD, DEFAULT_CDN_LOCKUP_AMOUNT); + + // Register the freshly created rail as the subscription's shared bandwidth rail. + if (cdnGroupKey != bytes32(0)) { + cdnGroupRail[cdnGroupKey] = cdnRailId; + } + + emit CDNPaymentRailsToppedUp( + dataSetId, + DEFAULT_CDN_LOCKUP_AMOUNT, + DEFAULT_CDN_LOCKUP_AMOUNT, + DEFAULT_CACHE_MISS_LOCKUP_AMOUNT, + DEFAULT_CACHE_MISS_LOCKUP_AMOUNT + ); + } else { + // Join the existing shared bandwidth rail, no second bandwidth lockup is charged. + cdnRailId = sharedCdnRailId; + emit CDNSubscriptionJoined(dataSetId, cdnRailId, cacheMissRailId); + } + + cdnRailRefCount[cdnRailId] += 1; + } + } + + /// @notice Returns true if a rail exists and has not been terminated (endEpoch == 0). + /// @dev getRail reverts on a finalized (zeroed) rail, treated as inactive. + function _railIsActive(FilecoinPayV1 payments, uint256 railId) internal view returns (bool) { + try payments.getRail(railId) returns (FilecoinPayV1.RailView memory rail) { + return rail.endEpoch == 0; + } catch { + return false; } } + /// @notice Terminates a data set's cache-miss rail and, when supplied, its shared bandwidth rail. + /// @dev The caller passes `cdnRailId == 0` when the shared bandwidth rail is still referenced by + /// sibling data sets, so only the last member tears the bandwidth rail down. function terminateCDNRails(FilecoinPayV1 payments, uint256 dataSetId, uint256 cacheMissRailId, uint256 cdnRailId) public { try payments.terminateRail(cacheMissRailId) {} catch {} - try payments.terminateRail(cdnRailId) {} catch {} + if (cdnRailId != 0) { + try payments.terminateRail(cdnRailId) {} catch {} + } emit CDNServiceTerminated(msg.sender, dataSetId, cacheMissRailId, cdnRailId); } @@ -184,9 +243,15 @@ library Rails { payments.settleRail(pdpRailId, block.number); payments.modifyRailLockup(pdpRailId, 0, 0); - if (cdnRailId != 0) { + // Cache-miss is per data set, the (possibly shared) bandwidth rail is supplied non-zero only + // when this is its last referencing data set. + if (cacheMissRailId != 0) { _teardownCDNRail(payments, cacheMissRailId); + } + if (cdnRailId != 0) { _teardownCDNRail(payments, cdnRailId); + } + if (cacheMissRailId != 0 || cdnRailId != 0) { emit CDNServiceTerminated(msg.sender, dataSetId, cacheMissRailId, cdnRailId); }