From 9b993c83dc65bfbf6f7057ee4a6941300e77e878 Mon Sep 17 00:00:00 2001 From: Julian Gruber Date: Mon, 15 Jun 2026 15:53:21 +0000 Subject: [PATCH 1/2] feat: share CDN bandwidth rail across a payer's data sets Multi-copy upload stores a piece in 2 data sets on 2 providers. With withCDN on both, the CDN bandwidth service was bought twice. This lets a client buy it once: the withCDN metadata value carries an optional group id, FWSS keys a shared bandwidth rail by keccak256(payer, groupId), and data sets in the same group join one rail instead of creating a second. The shared cdnRailId is the subscription identity, settled once via the new settleCDNBandwidthRail(cdnRailId, cdnAmount). The cache-miss rail stays per data set (its payee is the data set's SP). cdnRailRefCount tracks members so the bandwidth rail is torn down only at zero across the termination, deletion, and abandonment paths. Empty group id keeps the legacy one-rail-per-data-set behavior. --- SPEC.md | 6 + service_contracts/src/Errors.sol | 4 + .../src/FilecoinWarmStorageService.sol | 91 ++++++++++++- .../lib/FilecoinWarmStorageServiceLayout.sol | 2 + service_contracts/src/lib/Rails.sol | 121 ++++++++++++++---- 5 files changed, 190 insertions(+), 34 deletions(-) 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/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.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); } From 8332a5fd275232c3de40c13653e3e2143734808d Mon Sep 17 00:00:00 2001 From: Julian Gruber Date: Mon, 15 Jun 2026 17:04:24 +0000 Subject: [PATCH 2/2] chore: regenerate ABIs and storage layout JSON Run make gen and make update-abi so the checked-in artifacts match the shared bandwidth rail changes: settleCDNBandwidthRail and the UnknownCDNBandwidthRail error in the ABIs, and the cdnGroupRail (slot 23) and cdnRailRefCount (slot 24) mappings in the layout JSON. --- service_contracts/abi/Errors.abi.json | 11 +++++ .../abi/FilecoinWarmStorageService.abi.json | 29 +++++++++++++ .../lib/FilecoinWarmStorageServiceLayout.json | 42 +++++++++++++++++++ 3 files changed, 82 insertions(+) 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/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" + } + } } ]