diff --git a/service_contracts/.gitignore b/service_contracts/.gitignore index c9a8661d..1d3d7ffd 100644 --- a/service_contracts/.gitignore +++ b/service_contracts/.gitignore @@ -10,6 +10,7 @@ node_modules/ # Lock files package-lock.json +foundry.lock # Ignore IDEs -.idea +.idea \ No newline at end of file diff --git a/service_contracts/abi/FilecoinWarmStorageService.abi.json b/service_contracts/abi/FilecoinWarmStorageService.abi.json index 713d4bfa..7fe69b87 100644 --- a/service_contracts/abi/FilecoinWarmStorageService.abi.json +++ b/service_contracts/abi/FilecoinWarmStorageService.abi.json @@ -401,30 +401,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "isEpochProven", - "inputs": [ - { - "name": "dataSetId", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "epoch", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "migrate", diff --git a/service_contracts/src/Errors.sol b/service_contracts/src/Errors.sol index e6fd1e55..03fb45ae 100644 --- a/service_contracts/src/Errors.sol +++ b/service_contracts/src/Errors.sol @@ -201,7 +201,11 @@ library Errors { /// @param railId The rail ID error RailNotAssociated(uint256 railId); - /// @notice The epoch range is invalid (toEpoch must be > fromEpoch) + /// @notice The epoch range is invalid + /// @notice Will be emitted if any of the following conditions is NOT met: + /// @notice 1. fromEpoch must be less than toEpoch + /// @notice 2. toEpoch must be less than block number + /// @notice 3. toEpoch must be greater than the activation epoch /// @param fromEpoch The starting epoch (exclusive) /// @param toEpoch The ending epoch (inclusive) error InvalidEpochRange(uint256 fromEpoch, uint256 toEpoch); diff --git a/service_contracts/src/FilecoinWarmStorageService.sol b/service_contracts/src/FilecoinWarmStorageService.sol index a3d88895..90914c19 100644 --- a/service_contracts/src/FilecoinWarmStorageService.sol +++ b/service_contracts/src/FilecoinWarmStorageService.sol @@ -1141,43 +1141,6 @@ contract FilecoinWarmStorageService is return (epoch - activationEpoch) / maxProvingPeriod; } - /** - * @notice Checks if a specific epoch has been proven - * @dev Returns true only if the epoch belongs to a proven proving period - * @param dataSetId The ID of the data set to check - * @param epoch The epoch to check - * @return True if the epoch has been proven, false otherwise - */ - function isEpochProven(uint256 dataSetId, uint256 epoch) public view returns (bool) { - // Check if data set is active - if (provingActivationEpoch[dataSetId] == 0) { - return false; - } - - // Check if this epoch is before activation - if (epoch < provingActivationEpoch[dataSetId]) { - return false; - } - - // Check if this epoch is in the future (beyond current block) - if (epoch > block.number) { - return false; - } - - // Get the period this epoch belongs to - uint256 periodId = getProvingPeriodForEpoch(dataSetId, epoch); - - // Special case: current ongoing proving period - uint256 currentPeriod = getProvingPeriodForEpoch(dataSetId, block.number); - if (periodId == currentPeriod) { - // For the current period, check if it has been proven already - return provenThisPeriod[dataSetId]; - } - - // For past periods, check the provenPeriods bitmapping - return 0 != provenPeriods[dataSetId][periodId >> 8] & (1 << (periodId & 255)); - } - function max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } @@ -1470,7 +1433,8 @@ contract FilecoinWarmStorageService is require(totalEpochsRequested > 0, Errors.InvalidEpochRange(fromEpoch, toEpoch)); // If proving wasn't ever activated for this data set, don't pay anything - if (provingActivationEpoch[dataSetId] == 0) { + uint256 activationEpoch = provingActivationEpoch[dataSetId]; + if (activationEpoch == 0) { return ValidationResult({ modifiedAmount: 0, settleUpto: fromEpoch, @@ -1479,18 +1443,8 @@ contract FilecoinWarmStorageService is } // Count proven epochs and find the last proven epoch - uint256 provenEpochCount = 0; - uint256 lastProvenEpoch = fromEpoch; - - // Check each epoch in the range - for (uint256 epoch = fromEpoch + 1; epoch <= toEpoch; epoch++) { - bool isProven = isEpochProven(dataSetId, epoch); - - if (isProven) { - provenEpochCount++; - lastProvenEpoch = epoch; - } - } + (uint256 provenEpochCount, uint256 lastProvenEpoch) = + _findProvenEpochs(dataSetId, fromEpoch, toEpoch, activationEpoch); // If no epochs are proven, we can't settle anything if (provenEpochCount == 0) { @@ -1504,9 +1458,6 @@ contract FilecoinWarmStorageService is // Calculate the modified amount based on proven epochs uint256 modifiedAmount = (proposedAmount * provenEpochCount) / totalEpochsRequested; - // Calculate how many epochs were not proven (faulted) - uint256 faultedEpochs = totalEpochsRequested - provenEpochCount; - return ValidationResult({ modifiedAmount: modifiedAmount, settleUpto: lastProvenEpoch, // Settle up to the last proven epoch @@ -1514,6 +1465,63 @@ contract FilecoinWarmStorageService is }); } + function _findProvenEpochs(uint256 dataSetId, uint256 fromEpoch, uint256 toEpoch, uint256 activationEpoch) + internal + view + returns (uint256 provenEpochCount, uint256 settledUpTo) + { + require(toEpoch >= activationEpoch && toEpoch <= block.number, Errors.InvalidEpochRange(fromEpoch, toEpoch)); + uint256 currentPeriod = getProvingPeriodForEpoch(dataSetId, block.number); + + if (fromEpoch < activationEpoch - 1) { + fromEpoch = activationEpoch - 1; + } + + uint256 startingPeriod = getProvingPeriodForEpoch(dataSetId, fromEpoch + 1); + + // handle first period separately + uint256 startingPeriodDeadline = _calcPeriodDeadline(activationEpoch, startingPeriod); + + if (toEpoch < startingPeriodDeadline) { + if (_isPeriodProven(dataSetId, startingPeriod, currentPeriod)) { + provenEpochCount = toEpoch - fromEpoch; + settledUpTo = toEpoch; + } + } else { + if (_isPeriodProven(dataSetId, startingPeriod, currentPeriod)) { + provenEpochCount += (startingPeriodDeadline - fromEpoch); + } + + uint256 endingPeriod = getProvingPeriodForEpoch(dataSetId, toEpoch); + // loop through the proving periods between startingPeriod and endingPeriod + for (uint256 period = startingPeriod + 1; period < endingPeriod; period++) { + if (_isPeriodProven(dataSetId, period, currentPeriod)) { + provenEpochCount += maxProvingPeriod; + } + } + settledUpTo = _calcPeriodDeadline(activationEpoch, endingPeriod - 1); + + // handle the last period separately + if (_isPeriodProven(dataSetId, endingPeriod, currentPeriod)) { + provenEpochCount += (toEpoch - settledUpTo); + settledUpTo = toEpoch; + } + } + return (provenEpochCount, settledUpTo); + } + + function _isPeriodProven(uint256 dataSetId, uint256 periodId, uint256 currentPeriod) private view returns (bool) { + if (periodId == currentPeriod) { + return provenThisPeriod[dataSetId]; + } + uint256 isProven = provenPeriods[dataSetId][periodId >> 8] & (1 << (periodId & 255)); + return isProven != 0; + } + + function _calcPeriodDeadline(uint256 activationEpoch, uint256 periodId) private view returns (uint256) { + return activationEpoch + (periodId + 1) * maxProvingPeriod; + } + function railTerminated(uint256 railId, address terminator, uint256 endEpoch) external override { require(msg.sender == paymentsContractAddress, Errors.CallerNotPayments(paymentsContractAddress, msg.sender)); diff --git a/service_contracts/test/FilecoinWarmStorageService.t.sol b/service_contracts/test/FilecoinWarmStorageService.t.sol index 955e2402..67cb612d 100644 --- a/service_contracts/test/FilecoinWarmStorageService.t.sol +++ b/service_contracts/test/FilecoinWarmStorageService.t.sol @@ -14,7 +14,7 @@ import {CHALLENGES_PER_PROOF, FilecoinWarmStorageService} from "../src/FilecoinW import {FilecoinWarmStorageServiceStateView} from "../src/FilecoinWarmStorageServiceStateView.sol"; import {SignatureVerificationLib} from "../src/lib/SignatureVerificationLib.sol"; import {FilecoinWarmStorageServiceStateLibrary} from "../src/lib/FilecoinWarmStorageServiceStateLibrary.sol"; -import {FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol"; +import {FilecoinPayV1, IValidator} from "@fws-payments/FilecoinPayV1.sol"; import {MockERC20, MockPDPVerifier} from "./mocks/SharedMocks.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Errors} from "../src/Errors.sol"; @@ -4218,3 +4218,285 @@ contract FilecoinWarmStorageServiceUpgradeTest is Test { warmStorageService.migrate(address(0)); } } + +/** + * @notice Tests for validatePayment function - ensures optimized implementation + * maintains same behavior as the original loop-based version + */ +contract ValidatePaymentTest is FilecoinWarmStorageServiceTest { + /** + * @notice Test: All epochs proven - should pay full amount + */ + function testValidatePayment_AllEpochsProven() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Start proving + (uint64 maxProvingPeriod, uint256 challengeWindow,,) = viewContract.getPDPConfig(); + + // Capture activation epoch BEFORE calling nextProvingPeriod + uint256 _activationEpoch = vm.getBlockNumber(); + uint256 firstChallengeEpoch = _activationEpoch + maxProvingPeriod - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, firstChallengeEpoch, 100, ""); + + uint256 firstDeadline = _activationEpoch + maxProvingPeriod; + + // Submit proof for period 0 + vm.roll(firstChallengeEpoch); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Move just past the first deadline + vm.roll(firstDeadline + 1); + + uint256 secondDeadline = firstDeadline + maxProvingPeriod; + uint256 challengeEpoch1 = secondDeadline - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch1, 100, ""); + + // Submit proof for period 1 + vm.roll(challengeEpoch1); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Move to period 2 + vm.roll(secondDeadline + 1); + uint256 thirdDeadline = secondDeadline + maxProvingPeriod; + uint256 challengeEpoch2 = thirdDeadline - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch2, 100, ""); + + // Submit proof for period 2 + vm.roll(challengeEpoch2); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Now validate payment for epochs within these proven periods + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = _activationEpoch - 1; // exclusive start + uint256 toEpoch = _activationEpoch + (maxProvingPeriod * 3) - 1; // inclusive end, all 3 periods + uint256 proposedAmount = 1000e6; + + // Move past the periods we're validating, so that toEpoch becomes less than block.number + vm.roll(toEpoch + 1); + vm.prank(address(payments)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + // Should pay full amount since all epochs are proven + assertEq(result.modifiedAmount, proposedAmount, "Should pay full amount"); + assertEq(result.settleUpto, toEpoch, "Should settle to end epoch"); + } + + /** + * @notice Test: No epochs proven - should pay nothing + */ + function testValidatePayment_NoEpochsProven() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Start proving but don't submit any proofs + (uint64 maxProvingPeriod, uint256 challengeWindow,,) = viewContract.getPDPConfig(); + uint256 challengeEpoch = block.number + maxProvingPeriod - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch, 100, ""); + + uint256 activationEpoch = vm.getBlockNumber(); + + // Move forward 3 periods without submitting proofs + vm.roll(activationEpoch + (maxProvingPeriod * 3)); + + // Validate payment + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = activationEpoch - 1; // exclusive + uint256 toEpoch = activationEpoch + (maxProvingPeriod * 3) - 1; + uint256 proposedAmount = 1000e6; + + vm.prank(address(payments)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + // Should pay nothing + assertEq(result.modifiedAmount, 0, "Should pay nothing"); + assertEq(result.settleUpto, fromEpoch, "Should not settle"); + assertEq(result.note, "No proven epochs in the requested range"); + } + + /** + * @notice Test: Some epochs proven - should pay proportionally + */ + function testValidatePayment_SomeEpochsProven() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Start proving + (uint64 maxProvingPeriod, uint256 challengeWindow,,) = viewContract.getPDPConfig(); + uint256 firstChallengeEpoch = block.number + maxProvingPeriod - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, firstChallengeEpoch, 100, ""); + + uint256 activationEpoch = vm.getBlockNumber(); + + // Submit proof for period 0 + vm.roll(firstChallengeEpoch); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Move to period 1 - DON'T submit proof + uint256 deadline0 = activationEpoch + maxProvingPeriod; + vm.roll(deadline0 + 1); + uint256 challengeEpoch1 = deadline0 + 1 + maxProvingPeriod - (challengeWindow / 2); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch1, 100, ""); + + // Skip proof for period 1 + + // Move to period 2 and submit proof + uint256 deadline1 = deadline0 + maxProvingPeriod; + vm.roll(deadline1 + 1); + uint256 challengeEpoch2 = deadline1 + 1 + maxProvingPeriod - (challengeWindow / 2); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch2, 100, ""); + + vm.roll(challengeEpoch2); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Validate payment for all 3 periods + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = activationEpoch - 1; + uint256 toEpoch = activationEpoch + (maxProvingPeriod * 3) - 1; + uint256 proposedAmount = 3000e6; + + vm.roll(toEpoch + 1); + + vm.prank(address(payments)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + // Should pay 2/3 of amount (2 proven periods out of 3) + uint256 totalEpochs = toEpoch - fromEpoch; + uint256 provenEpochs = maxProvingPeriod * 2; + uint256 expectedAmount = (proposedAmount * provenEpochs) / totalEpochs; + + assertEq(result.modifiedAmount, expectedAmount, "Should pay for 2/3 of epochs"); + assertTrue(result.settleUpto > fromEpoch, "Should settle past start"); + } + + /** + * @notice Test: Proving never activated - should pay nothing + */ + function testValidatePayment_ProvingNeverActivated() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Don't start proving at all + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = block.number; + uint256 toEpoch = block.number + 1000; + uint256 proposedAmount = 1000e6; + + vm.prank(address(payments)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + assertEq(result.modifiedAmount, 0, "Should pay nothing"); + assertEq(result.settleUpto, fromEpoch, "Should not settle"); + assertEq(result.note, "Proving never activated for this data set"); + } + + /** + * @notice Test: Request range before activation - should pay nothing + */ + function testValidatePayment_BeforeActivation() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Move forward to create some block height + vm.roll(block.number + 1000); + + // Start proving + (uint64 maxProvingPeriod, uint256 challengeWindow,,) = viewContract.getPDPConfig(); + uint256 challengeEpoch = block.number + maxProvingPeriod - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch, 100, ""); + + uint256 activationEpoch = vm.getBlockNumber(); + + // Try to validate for epochs before activation + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = activationEpoch - 500; + uint256 toEpoch = activationEpoch - 100; + uint256 proposedAmount = 1000e6; + + vm.prank(address(payments)); + vm.expectRevert(abi.encodeWithSelector(Errors.InvalidEpochRange.selector, fromEpoch, toEpoch)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + assertEq(result.modifiedAmount, 0, "Should pay nothing for pre-activation epochs"); + } + + /** + * @notice Test: Partial period coverage - epochs span within a proven period + */ + function testValidatePayment_PartialPeriodCoverage() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + + // Start proving + (uint64 maxProvingPeriod, uint256 challengeWindow,,) = viewContract.getPDPConfig(); + uint256 challengeEpoch = block.number + maxProvingPeriod - (challengeWindow / 2); + + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch, 100, ""); + + uint256 activationEpoch = vm.getBlockNumber(); + + // Submit proof for period 0 + vm.roll(challengeEpoch); + vm.prank(address(mockPDPVerifier)); + pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, CHALLENGES_PER_PROOF); + + // Validate payment for middle portion of period 0 + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + uint256 fromEpoch = activationEpoch + 100; // Start 100 epochs into period + uint256 toEpoch = activationEpoch + maxProvingPeriod - 100; // End 100 epochs before period ends + uint256 proposedAmount = 1000e6; + + vm.roll(toEpoch + 1); + + vm.prank(address(payments)); + IValidator.ValidationResult memory result = + pdpServiceWithPayments.validatePayment(info.pdpRailId, proposedAmount, fromEpoch, toEpoch, 0); + + // Since the period is proven, should pay full amount for the requested range + assertEq(result.modifiedAmount, proposedAmount, "Should pay full amount for proven period"); + assertEq(result.settleUpto, toEpoch, "Should settle to end of range"); + } + + /** + * @notice Test: Invalid rail ID - should revert + */ + function testValidatePayment_InvalidRailId() public { + uint256 invalidRailId = 999999; + + vm.prank(address(payments)); + vm.expectRevert(abi.encodeWithSelector(Errors.RailNotAssociated.selector, invalidRailId)); + pdpServiceWithPayments.validatePayment(invalidRailId, 1000e6, 100, 200, 0); + } + + /** + * @notice Test: Invalid epoch range - should revert + */ + function testValidatePayment_InvalidEpochRange() public { + uint256 dataSetId = createDataSetForServiceProviderTest(sp1, client, "Test"); + FilecoinWarmStorageService.DataSetInfoView memory info = viewContract.getDataSet(dataSetId); + + // fromEpoch >= toEpoch + vm.prank(address(payments)); + vm.expectRevert(abi.encodeWithSelector(Errors.InvalidEpochRange.selector, 200, 200)); + pdpServiceWithPayments.validatePayment(info.pdpRailId, 1000e6, 200, 200, 0); + } +}