Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion service_contracts/lib/fws-payments
75 changes: 75 additions & 0 deletions service_contracts/src/PandoraService.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
event DataSetRailCreated(uint256 indexed dataSetId, uint256 railId, address payer, address payee, bool withCDN);
event RailRateUpdated(uint256 indexed dataSetId, uint256 railId, uint256 newRate);
event PieceMetadataAdded(uint256 indexed dataSetId, uint256 pieceId, string metadata);
event RailTerminatedInService(uint256 indexed railId, address indexed terminator, uint256 endEpoch);

// Constants
uint256 public constant NO_CHALLENGE_SCHEDULED = 0;
Expand Down Expand Up @@ -118,6 +119,15 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
// Track when proving was first activated for each data set
mapping(uint256 => uint256) public provingActivationEpoch;

// Rail termination tracking
struct RailTerminationStatus {
bool isTerminated;
uint256 endEpoch;
}

// Mapping from rail ID to termination status
mapping(uint256 => RailTerminationStatus) public railTerminationStatus;

// ========== Storage Provider Registry State ==========

uint256 public nextServiceProviderId = 1;
Expand Down Expand Up @@ -495,6 +505,9 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
DataSetInfo storage info = dataSetInfo[dataSetId];
require(info.railId != 0, "Data set not registered with payment system");

// Check if the rail is terminated
require(!railTerminationStatus[info.railId].isTerminated, "Cannot add pieces: rail is terminated");

// Get the payer address for this data set
address payer = info.payer;
require(extraData.length > 0, "Extra data required for adding pieces");
Expand Down Expand Up @@ -532,6 +545,13 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
"Data set not registered with payment system"
);

// Check if rail is terminated and beyond end epoch
RailTerminationStatus memory termStatus = railTerminationStatus[info.railId];
require(
!termStatus.isTerminated || block.number <= termStatus.endEpoch,
"Operation rejected: rail terminated and beyond end epoch"
);
Comment on lines +549 to +553
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we refactor this into a method like requireActivePaymentRail(railId) or requireUnterminatedPaymentRail(railId)? It is being used without differences in 3 places

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add termination logic for the FilCDN rails when we have decided on this matter


// Get the payer address for this data set
address payer = info.payer;

Expand Down Expand Up @@ -561,6 +581,16 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
uint256, /*seed*/
uint256 challengeCount
) external onlyPDPVerifier {
// Check if rail is terminated and beyond end epoch
DataSetInfo storage info = dataSetInfo[dataSetId];
if (info.railId != 0) {
RailTerminationStatus memory termStatus = railTerminationStatus[info.railId];
require(
!termStatus.isTerminated || block.number <= termStatus.endEpoch,
"Operation rejected: rail terminated and beyond end epoch"
);
}

if (provenThisPeriod[dataSetId]) {
revert("Only one proof of possession allowed per proving period. Open a new proving period.");
}
Expand Down Expand Up @@ -594,6 +624,16 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
external
onlyPDPVerifier
{
// Check if rail is terminated and beyond end epoch
DataSetInfo storage info = dataSetInfo[dataSetId];
if (info.railId != 0) {
RailTerminationStatus memory termStatus = railTerminationStatus[info.railId];
require(
!termStatus.isTerminated || block.number <= termStatus.endEpoch,
"Operation rejected: rail terminated and beyond end epoch"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these errors really mean -- "this entire dataset is unrecoverable" it should probably be a little more descriptive in this direction.

For example

"Operation rejected: rail terminated and finalized -- proof set must be removed to make progress"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the prefix Operation rejected, since user will already see in the transaction data that it has been required

);
}

// initialize state for new data set
if (provingDeadlines[dataSetId] == NO_PROVING_DEADLINE) {
uint256 firstDeadline = block.number + getMaxProvingPeriod();
Expand Down Expand Up @@ -1356,4 +1396,39 @@ contract PandoraService is PDPListener, IValidator, Initializable, UUPSUpgradeab
note: ""
});
}

/**
* @notice Called when a payment rail is terminated in the Payments contract
* @dev Implements the IValidator interface function
* @param railId ID of the payment rail being terminated
* @param terminator Address that initiated the termination
* @param endEpoch The final epoch up to which the rail can be settled
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might not understand settling fully, but does this mean funds will be locked inside the rail if I didn't settle them before endEpoch?

*/
function railTerminated(uint256 railId, address terminator, uint256 endEpoch) external override {
// Only payments contract can call this
require(msg.sender == paymentsContractAddress, "Only payments contract can terminate rails");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since only the one payments contract can do this, shall we drop the terminator argument?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juliangruber The terminator is the end user who terminated (payer, payee or operator ?). I'll improve the name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that is the case then the error message in the require() is confusing


// Verify rail exists in our mapping
uint256 dataSetId = railToDataSet[railId];
require(dataSetId != 0, "Rail not associated with any data set");

// Update termination status
railTerminationStatus[railId] = RailTerminationStatus({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could make this is a simple mapping[uint256] => uint256 just storing the end epoch and letting a 0 value stand in for "not terminated"

isTerminated: true,
endEpoch: endEpoch
});

emit RailTerminatedInService(railId, terminator, endEpoch);
}

/**
* @notice Check if a rail is terminated and get its end epoch
* @param railId The ID of the rail to check
* @return isTerminated Whether the rail is terminated
* @return endEpoch The end epoch for the terminated rail (0 if not terminated)
*/
function isRailTerminated(uint256 railId) external view returns (bool isTerminated, uint256 endEpoch) {
RailTerminationStatus memory status = railTerminationStatus[railId];
return (status.isTerminated, status.endEpoch);
}
}
245 changes: 245 additions & 0 deletions service_contracts/test/PandoraService.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {PDPListener, PDPVerifier} from "@pdp/PDPVerifier.sol";
import {PandoraService} from "../src/PandoraService.sol";
import {MyERC1967Proxy} from "@pdp/ERC1967Proxy.sol";
import {Cids} from "@pdp/Cids.sol";
import {IPDPTypes} from "@pdp/interfaces/IPDPTypes.sol";
import {Payments, IValidator} from "@fws-payments/Payments.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
Expand Down Expand Up @@ -504,6 +505,250 @@ contract PandoraServiceTest is Test {
// Constants for calculations
uint256 constant COMMISSION_MAX_BPS = 10000;

// ============= Rail Termination Tests =============

function setupDataSetForTerminationTests() internal returns (uint256, uint256) {
// Register and approve storage provider
vm.prank(storageProvider);
pdpServiceWithPayments.registerServiceProvider(validServiceUrl, validPeerId);
pdpServiceWithPayments.approveServiceProvider(storageProvider);

// Prepare data set creation data
PandoraService.DataSetCreateData memory createData = PandoraService.DataSetCreateData({
metadata: "Test Data Set for Termination",
payer: client,
signature: FAKE_SIGNATURE,
withCDN: false
});

bytes memory encodedData = abi.encode(createData.metadata, createData.payer, createData.withCDN, createData.signature);

// Setup client payment approval and deposit
vm.startPrank(client);
payments.setOperatorApproval(
address(mockUSDFC),
address(pdpServiceWithPayments),
true,
1000e6, // rate allowance
1000e6, // lockup allowance
365 days // max lockup period
);
mockUSDFC.approve(address(payments), 100e6);
payments.deposit(address(mockUSDFC), client, 100e6);
vm.stopPrank();

// Create data set
makeSignaturePass(client);
vm.prank(storageProvider);
uint256 dataSetId = mockPDPVerifier.createDataSet(address(pdpServiceWithPayments), encodedData);

// Get rail ID
uint256 railId = pdpServiceWithPayments.getDataSetRailId(dataSetId);

return (dataSetId, railId);
}

function testOnlyPaymentsContractCanCallRailTermination() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Try to call railTerminated from non-payments contract address
vm.prank(client);
vm.expectRevert("Only payments contract can terminate rails");
pdpServiceWithPayments.railTerminated(railId, client, block.number + 100);

// Try from storage provider
vm.prank(storageProvider);
vm.expectRevert("Only payments contract can terminate rails");
pdpServiceWithPayments.railTerminated(railId, storageProvider, block.number + 100);

// Try from deployer/owner
vm.expectRevert("Only payments contract can terminate rails");
pdpServiceWithPayments.railTerminated(railId, deployer, block.number + 100);

// Verify rail is not terminated
(bool isTerminated, uint256 endEpoch) = pdpServiceWithPayments.isRailTerminated(railId);
assertFalse(isTerminated, "Rail should not be terminated");
assertEq(endEpoch, 0, "End epoch should be 0");
}

function testRailTerminationByPaymentsContract() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Call railTerminated from payments contract
uint256 terminationEndEpoch = block.number + 1000;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Verify rail is terminated
(bool isTerminated, uint256 endEpoch) = pdpServiceWithPayments.isRailTerminated(railId);
assertTrue(isTerminated, "Rail should be terminated");
assertEq(endEpoch, terminationEndEpoch, "End epoch should match");
}

function testPiecesAddedFailsAfterRailTermination() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Terminate the rail
uint256 terminationEndEpoch = block.number + 1000;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Prepare pieces data
Cids.Cid memory cid = Cids.Cid({data: hex"1234567890abcdef"});
IPDPTypes.PieceData[] memory pieces = new IPDPTypes.PieceData[](1);
pieces[0] = IPDPTypes.PieceData({piece: cid, rawSize: 1024});

bytes memory extraData = abi.encode(FAKE_SIGNATURE, "piece metadata");

// Try to add pieces - should fail
makeSignaturePass(client);
vm.prank(address(mockPDPVerifier));
vm.expectRevert("Cannot add pieces: rail is terminated");
pdpServiceWithPayments.piecesAdded(dataSetId, 0, pieces, extraData);
}

function testOperationsFailAfterRailEndEpoch() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Terminate the rail with end epoch in near future
uint256 terminationEndEpoch = block.number + 10;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Move past the end epoch
vm.roll(terminationEndEpoch + 1);

// Test piecesScheduledRemove fails after end epoch
uint256[] memory pieceIds = new uint256[](1);
pieceIds[0] = 0;
bytes memory scheduleRemoveData = abi.encode(FAKE_SIGNATURE);

makeSignaturePass(client);
vm.prank(address(mockPDPVerifier));
vm.expectRevert("Operation rejected: rail terminated and beyond end epoch");
pdpServiceWithPayments.piecesScheduledRemove(dataSetId, pieceIds, scheduleRemoveData);

// Test possessionProven fails after end epoch
vm.prank(address(mockPDPVerifier));
vm.expectRevert("Operation rejected: rail terminated and beyond end epoch");
pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, 5);

// Test nextProvingPeriod fails after end epoch
vm.prank(address(mockPDPVerifier));
vm.expectRevert("Operation rejected: rail terminated and beyond end epoch");
pdpServiceWithPayments.nextProvingPeriod(dataSetId, block.number + 100, 100, "");
}

function testOperationsAllowedBeforeRailEndEpoch() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// First initialize proving period before termination
uint256 maxProvingPeriod = pdpServiceWithPayments.getMaxProvingPeriod();
uint256 challengeWindow = pdpServiceWithPayments.challengeWindow();
uint256 challengeEpoch = block.number + maxProvingPeriod - 30;

vm.prank(address(mockPDPVerifier));
pdpServiceWithPayments.nextProvingPeriod(dataSetId, challengeEpoch, 100, "");

// Terminate the rail with end epoch well in the future (after the proving deadline)
uint256 terminationEndEpoch = block.number + 5000;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Verify we're still before end epoch
assertTrue(block.number <= terminationEndEpoch, "Should be before end epoch");

// Test piecesScheduledRemove works before end epoch
uint256[] memory pieceIds = new uint256[](1);
pieceIds[0] = 0;
bytes memory scheduleRemoveData = abi.encode(FAKE_SIGNATURE);

makeSignaturePass(client);
vm.prank(address(mockPDPVerifier));
// Should not revert
pdpServiceWithPayments.piecesScheduledRemove(dataSetId, pieceIds, scheduleRemoveData);

// Move to challenge window for possessionProven
uint256 provingDeadline = pdpServiceWithPayments.provingDeadlines(dataSetId);
uint256 challengeWindowStart = provingDeadline - challengeWindow;
vm.roll(challengeWindowStart + 1);

// Test possessionProven works before end epoch
vm.prank(address(mockPDPVerifier));
// Should not revert
pdpServiceWithPayments.possessionProven(dataSetId, 100, 12345, 5);

// Move past proving deadline
vm.roll(provingDeadline + 1);

// Test nextProvingPeriod for next period works before end epoch
vm.prank(address(mockPDPVerifier));
// Should not revert
pdpServiceWithPayments.nextProvingPeriod(
dataSetId,
block.number + maxProvingPeriod - 30,
100,
""
);
}

function testStorageProviderChangeAllowedAfterTermination() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Terminate the rail
uint256 terminationEndEpoch = block.number + 10;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Move past the end epoch
vm.roll(terminationEndEpoch + 1);

// Register and approve a new storage provider
address newStorageProvider = address(0x999);
vm.deal(newStorageProvider, 10 ether);
vm.prank(newStorageProvider);
pdpServiceWithPayments.registerServiceProvider("https://newsp.example.com", hex"abcdef");
pdpServiceWithPayments.approveServiceProvider(newStorageProvider);

// Storage provider change should still work after termination
vm.prank(address(mockPDPVerifier));
// Should not revert
pdpServiceWithPayments.storageProviderChanged(dataSetId, storageProvider, newStorageProvider, "");

// Verify the change took effect
(, address payee) = pdpServiceWithPayments.getDataSetParties(dataSetId);
assertEq(payee, newStorageProvider, "Storage provider should be updated");
}

function testDataSetDeletionAllowedAfterTermination() public {
(uint256 dataSetId, uint256 railId) = setupDataSetForTerminationTests();

// Terminate the rail
uint256 terminationEndEpoch = block.number + 10;
vm.prank(address(payments));
pdpServiceWithPayments.railTerminated(railId, client, terminationEndEpoch);

// Move past the end epoch
vm.roll(terminationEndEpoch + 1);

// Data set deletion should still work after termination
bytes memory deleteData = abi.encode(FAKE_SIGNATURE);

makeSignaturePass(client);
vm.prank(address(mockPDPVerifier));
// Should not revert
pdpServiceWithPayments.dataSetDeleted(dataSetId, 100, deleteData);
}

function testRailTerminationForNonExistentRail() public {
// Try to terminate a non-existent rail
uint256 nonExistentRailId = 999;

vm.prank(address(payments));
vm.expectRevert("Rail not associated with any data set");
pdpServiceWithPayments.railTerminated(nonExistentRailId, client, block.number + 100);
}

function testGlobalParameters() public view {
// These parameters should be the same as in SimplePDPService
assertEq(pdpServiceWithPayments.getMaxProvingPeriod(), 2880, "Max proving period should be 2880 epochs");
Expand Down