Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
cdfc712
WIP porep
wjmelements Apr 1, 2026
87667b8
PoRepDeal
wjmelements Apr 16, 2026
6a31a9c
getActorId
wjmelements Apr 16, 2026
cd9af78
allow multiple deals per sector
wjmelements Apr 16, 2026
571af49
authenticate with nonce and move payments operator back to service
wjmelements Apr 17, 2026
6440ca3
expiry and lockup
wjmelements Apr 17, 2026
850489d
fix overlock with one time payments
wjmelements Apr 20, 2026
c6ba636
handle amortization after endEpoch
wjmelements Apr 20, 2026
d2bbf01
onBadSector
wjmelements Apr 21, 2026
7333617
bounty insurance via service fee
wjmelements Apr 21, 2026
7c17b8a
termination
wjmelements Apr 21, 2026
27d930d
move IValidator back to Service and share termination path
wjmelements Apr 21, 2026
5b1f1af
test payments and bounties
wjmelements Apr 22, 2026
dde7e37
test(PoRepDealSectorStatus): assert rail finalized after termination
wjmelements Apr 22, 2026
1831e48
test(PoRepDealExtend): add test coverage for extend
wjmelements Apr 22, 2026
c1f34fc
use LibClone for CREATE2 calculation
wjmelements Apr 22, 2026
0fe7918
rm extra cast
wjmelements Apr 22, 2026
d4ba9ef
test(PoRepService): add test coverage for NotMiner error
wjmelements Apr 22, 2026
10d7078
NotMiner error
wjmelements Apr 22, 2026
b2c26d9
document that the minerActor is validated in the deal
wjmelements Apr 22, 2026
c897e58
CommP CIDv1 digest is 32 bytes
wjmelements Apr 22, 2026
f323c06
pull fvm-solidity fixes
wjmelements Apr 24, 2026
4c32e31
fix: update fws-payments submodule URL to filecoin-pay
wjmelements Apr 24, 2026
b5d7593
feat(porep): define custom errors and add test coverage
wjmelements Apr 27, 2026
516c13a
feat(porep): emit DealCreated event and verify in tests
wjmelements Apr 27, 2026
e75d634
test(porep): add authenticateDeal unauthorized coverage for payee, re…
wjmelements Apr 28, 2026
40a3ead
rm duplicated import
wjmelements Apr 29, 2026
b2d00aa
fix(PoRepDeal): pass sector location to sectorExpired for Dead valida…
wjmelements Apr 29, 2026
a1dca34
test: SectorNotDead
wjmelements Apr 29, 2026
101ca0a
.gas-snapshot
wjmelements Apr 29, 2026
f4b767f
avoid recalculating slot for sectors[sectorId]
wjmelements Apr 29, 2026
37ef865
test(PoRepDeal): expand revert coverage and remove dead SectorNotInDe…
wjmelements Apr 30, 2026
a9dfa5d
test(PoRepPayee): assert sudo forwards inner call revert data
wjmelements Apr 30, 2026
1f4dc90
update .gas-snapshot
wjmelements Apr 30, 2026
166bd89
drop trailing cbor metadata from contract bytecode
wjmelements May 1, 2026
b465f34
ci(gas): fetch full depth to resolve nested submodule commits
wjmelements May 1, 2026
6e4f997
Revert "ci(gas): fetch full depth to resolve nested submodule commits"
wjmelements May 1, 2026
efc9c99
dump submodule info
wjmelements May 1, 2026
173af12
test: forge snapshot --isolate
wjmelements May 1, 2026
6155d6e
workaround with no-match
wjmelements May 1, 2026
e3ceb73
dep: point lib/fvm-solidity to main after merge
wjmelements May 1, 2026
e7c9358
Merge remote-tracking branch 'origin/main' into porep
wjmelements Jul 29, 2026
58c3ab5
chore: update .gas-snapshot
wjmelements Jul 29, 2026
345ba41
chore: bump gas CI forge from 1.3.5 to 1.7.1
wjmelements Jul 29, 2026
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
45 changes: 45 additions & 0 deletions .github/workflows/gas.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Gas Snapshot

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
gas-snapshot:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.7.1

- name: Delete gas snapshot
run: rm -f service_contracts/.gas-snapshot

- name: Regenerate gas snapshot
run: |
export PATH="/home/runner/.config/.foundry/bin:$PATH"
make -C service_contracts test

- name: Dump submodules
run: |
git submodule foreach --recursive '
depth=$(echo "$displaypath" | tr -cd "/" | wc -c)
indent=$(printf " %.0s" $(seq 1 $depth))
printf "%s%s → %s\n" "$indent" "$displaypath" "$sha1"
'

- name: Check for uncommitted changes
run: |
git add service_contracts/.gas-snapshot
git diff --cached --exit-code service_contracts/.gas-snapshot || {
echo "Gas snapshot is out of date. Run 'make test' locally and commit the updated .gas-snapshot file."
exit 1
}
8 changes: 7 additions & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
url = https://github.com/OpenZeppelin/openzeppelin-contracts
[submodule "service_contracts/lib/fws-payments"]
path = service_contracts/lib/fws-payments
url = https://github.com/FilOzone/fws-payments
url = https://github.com/FilOzone/filecoin-pay
[submodule "service_contracts/lib/pdp"]
path = service_contracts/lib/pdp
url = https://github.com/FilOzone/pdp
Expand All @@ -16,3 +16,9 @@
[submodule "service_contracts/lib/openzeppelin-contracts-upgradeable"]
path = service_contracts/lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
[submodule "service_contracts/lib/fvm-solidity"]
path = service_contracts/lib/fvm-solidity
url = https://github.com/filecoin-project/fvm-solidity
[submodule "service_contracts/lib/solady"]
path = service_contracts/lib/solady
url = https://github.com/Vectorized/solady
863 changes: 863 additions & 0 deletions service_contracts/.gas-snapshot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm inclined to remove this file. I introduced it mainly to check for solc bytecode improvements and regressions. It has been a pain to get it to be deterministic and I suspect it will cause other people trouble in the future, and more trouble than it is worth.

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion service_contracts/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ CHALLENGE_FINALITY ?=

# Default target
.PHONY: default
default: build test
default: build

# All target including installation
.PHONY: all
Expand Down Expand Up @@ -86,6 +86,7 @@ check-tools:
.PHONY: test
test:
forge test --via-ir -vv
@forge snapshot --isolate --no-match-test "Fuzz" --no-match-contract "MetadataSignatureFixturesTest"

# Clean build artifacts
.PHONY: clean
Expand Down
12 changes: 11 additions & 1 deletion service_contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ solc = "0.8.30"
via_ir = true
optimizer = true
optimizer_runs = 200
# deterministic compilation output
cbor_metadata = false
bytecode_hash = "none"

ignored_error_codes = [
2394, # don't need to clear tstore
2519, # shadowed declarations
3628, # https://github.com/argotorg/solidity/issues/10159
3860, # tests exceed size limit
]

# For dependencies
remappings = [
'@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/',
Expand All @@ -19,8 +28,9 @@ remappings = [
'@fws-payments/=lib/fws-payments/src/',
'@pdp/=lib/pdp/src/',
'@session-key-registry/=lib/session-key-registry/src/',
'@fvm-solidity/=lib/pdp/lib/fvm-solidity/src/',
'@fvm-solidity/=lib/fvm-solidity/src/',
'@pythnetwork/pyth-sdk-solidity/=lib/pdp/lib/pyth-sdk-solidity/',
'@solady/=lib/solady/src/',
]

# Allow reading test data files
Expand Down
1 change: 1 addition & 0 deletions service_contracts/lib/fvm-solidity
Submodule fvm-solidity added at 2b3412
1 change: 1 addition & 0 deletions service_contracts/lib/solady
Submodule solady added at acd959
250 changes: 250 additions & 0 deletions service_contracts/src/PoRepDeal.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.30;

import {FilecoinPayV1} from "@fws-payments/FilecoinPayV1.sol";
import {FVMSector, SectorStatus} from "@fvm-solidity/FVMSector.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IPoRepService {
function updateLockups(uint64 nonce, uint256 railId, uint256 payment, uint256 remaining) external;
function terminate(uint64 nonce, uint256 railId, uint64 provider, address sender) external;
}

contract PoRepDeal {
address public immutable SERVICE;
address public immutable CLIENT;
uint64 public immutable PROVIDER;
FilecoinPayV1 private immutable PAYMENTS;
uint256 public immutable RAIL_ID;
IERC20 public immutable TOKEN;
uint256 public immutable TOKENS_PER_BYTE_PER_EPOCH;
uint64 private immutable NONCE;

struct Info {
uint64 settledEpoch;
uint64 endEpoch;
uint32 faultedSectorCount;
uint96 totalActiveSize;
}

Info public info;

enum PieceStatus {
UNAUTHORIZED,
AUTHORIZED,
ACTIVE
}

// TODO move to child contract
mapping(bytes32 pieceId => PieceStatus) pieces;

// TODO move to child contract
struct SectorInfo {
uint96 dealSize;
uint8 failed;
}

mapping(uint256 sectorId => SectorInfo) public sectors;

error Unauthorized();
error PieceAlreadyAuthorized(bytes32 pieceDigest);
error WrongProvider(uint64 minerId);
error CommitmentTooShort(uint64 minimumCommitmentEpoch, uint64 endEpoch);
error PieceNotAuthorized(bytes32 pieceDigest);
error DealExpired();
error DealFaulted();
error DealNotExpired();
error SectorNotInDeal(uint64 sectorId);
error SectorAlreadyFailed(uint64 sectorId);
error SectorNotFailed(uint64 sectorId);
error SectorNotFaulty(uint64 sectorId);
error SectorNotActive(uint64 sectorId);
error SectorNotDead(uint64 sectorId);

constructor(
address service,
address client,
uint64 provider,
FilecoinPayV1 payments,
uint256 railId,
IERC20 token,
uint256 tokensPerBytePerEpoch,
uint64 dealEndEpoch,
uint64 nonce
) {
SERVICE = service;
CLIENT = client;
PROVIDER = provider;
PAYMENTS = payments;
TOKEN = token;
TOKENS_PER_BYTE_PER_EPOCH = tokensPerBytePerEpoch;
RAIL_ID = railId;
NONCE = nonce;
info.endEpoch = dealEndEpoch;
}

function _onlyClient() internal view {
require(msg.sender == CLIENT, Unauthorized());
}

function _onlyService() internal view {
require(msg.sender == SERVICE, Unauthorized());
}

modifier onlyClient() {
_onlyClient();
_;
}

modifier onlyService() {
_onlyService();
_;
}

// TODO allow provider to addPieces with client authorization
function addPieces(bytes32[] calldata pieceDigests) external onlyClient {
for (uint256 i = 0; i < pieceDigests.length; i++) {
bytes32 pieceDigest = pieceDigests[i];
require(pieces[pieceDigest] == PieceStatus.UNAUTHORIZED, PieceAlreadyAuthorized(pieceDigest));
pieces[pieceDigest] = PieceStatus.AUTHORIZED;
}
}

function pieceAdded(
uint64 minerId,
bytes32 pieceDigest,
uint64 sectorId,
uint64 minimumCommitmentEpoch,
uint64 paddedSize
) external onlyService {
require(minerId == PROVIDER, WrongProvider(minerId));

// this also enforces block.number < info.endEpoch because minimum commitment is 180 days
require(minimumCommitmentEpoch >= info.endEpoch, CommitmentTooShort(minimumCommitmentEpoch, info.endEpoch));

require(pieces[pieceDigest] == PieceStatus.AUTHORIZED, PieceNotAuthorized(pieceDigest));
pieces[pieceDigest] = PieceStatus.ACTIVE;

sectors[sectorId].dealSize += paddedSize;

// TODO only amortize once per SectorContentChanged notification
uint256 prevSize = info.totalActiveSize;
uint256 newSize = prevSize + paddedSize;
uint256 prevRate = info.faultedSectorCount > 0 ? 0 : prevSize * TOKENS_PER_BYTE_PER_EPOCH;
uint256 newRate = newSize * TOKENS_PER_BYTE_PER_EPOCH;
amortize((block.number - info.settledEpoch) * prevRate, (info.endEpoch - block.number) * newRate);
info.settledEpoch = uint64(block.number);
info.totalActiveSize = uint96(newSize);
}

function extend(uint64 epochs) external onlyClient {
require(block.number < info.endEpoch, DealExpired());
require(info.faultedSectorCount == 0, DealFaulted());
uint64 newEndEpoch = info.endEpoch + epochs;
uint256 rate = info.totalActiveSize * TOKENS_PER_BYTE_PER_EPOCH;
amortize((block.number - info.settledEpoch) * rate, (newEndEpoch - block.number) * rate);
info.endEpoch = newEndEpoch;
info.settledEpoch = uint64(block.number);
}

function amortize(uint256 payment, uint256 remaining) internal {
IPoRepService(SERVICE).updateLockups(NONCE, RAIL_ID, payment, remaining);
}

function amortize() public {
if (info.faultedSectorCount == 0) {
amortizeHealthy();
return;
}
info.settledEpoch = uint64(block.number);
}

function amortizeHealthy() internal {
uint256 rate = info.totalActiveSize * TOKENS_PER_BYTE_PER_EPOCH;
if (block.number < info.endEpoch) {
// live
amortize((block.number - info.settledEpoch) * rate, (info.endEpoch - block.number) * rate);
} else {
// expired
amortize((info.endEpoch - info.settledEpoch) * rate, 0);
}
info.settledEpoch = uint64(block.number);
}

function getInsuranceFunds() internal view returns (uint256 funds) {
(funds,,,) = PAYMENTS.accounts(TOKEN, address(this));
}

function payoutBounty(address recipient, uint256 bounty) internal {
if (bounty > 0) {
PAYMENTS.withdrawTo(TOKEN, recipient, bounty);
}
}

// Pass NO_DEADLINE and NO_PARTITION once the sector has been compacted via CompactPartitions.
function sectorExpired(uint64 sectorId, int64 deadline, int64 partition, address recipient) external {
require(block.number < info.endEpoch, DealExpired());
require(sectors[sectorId].dealSize > 0, SectorNotInDeal(sectorId));
require(
FVMSector.validateSectorStatus(PROVIDER, sectorId, SectorStatus.Dead, deadline, partition),
SectorNotDead(sectorId)
);

// this is unrecoverable, so terminate
info.endEpoch = uint64(block.number);
amortize();
terminate(recipient, 0, address(0));
}

function sectorFaulty(uint64 sectorId, int64 deadline, int64 partition, address recipient) external {
require(block.number < info.endEpoch, DealExpired());

SectorInfo storage sectorInfo = sectors[sectorId];
require(sectorInfo.dealSize > 0, SectorNotInDeal(sectorId));
require(sectorInfo.failed == 0, SectorAlreadyFailed(sectorId));
require(
FVMSector.validateSectorStatus(PROVIDER, sectorId, SectorStatus.Faulty, deadline, partition),
SectorNotFaulty(sectorId)
);

sectorInfo.failed = 1;
if (info.faultedSectorCount == 0) {
amortizeHealthy();
info.faultedSectorCount = 1;
payoutBounty(recipient, getInsuranceFunds() / 2);
} else {
info.faultedSectorCount++;
}
}

// SPs should call this after DeclareFaultsRecovered and a successful Window PoSt
function sectorRecovered(uint64 sectorId, int64 deadline, int64 partition) external {
SectorInfo storage sectorInfo = sectors[sectorId];
// a sector cannot have been marked faulty unless it is already in the deal
require(sectorInfo.failed == 1, SectorNotFailed(sectorId));
require(
FVMSector.validateSectorStatus(PROVIDER, sectorId, SectorStatus.Active, deadline, partition),
SectorNotActive(sectorId)
);

sectorInfo.failed = 0;

if (--info.faultedSectorCount == 0) {
info.settledEpoch = uint64(block.number);
}
}

function terminate(address recipient, uint64 provider, address receiver) internal {
// if termination is caused by a dead sector, the keeper gets the insurance
// otherwise, the insurance is paid to the order of the PROVIDER
IPoRepService(SERVICE).terminate(NONCE, RAIL_ID, provider, receiver);
payoutBounty(recipient, getInsuranceFunds());
}

// After healthy deal termination, the remainder of the insurance funds can be collected by the receiver in exchange for rail cleanup
function sweep(address recipient) external {
require(block.number > info.endEpoch, DealNotExpired());
amortize();
terminate(recipient, PROVIDER, msg.sender);
}
}
Loading
Loading