A non-custodial, oracle-free proof-gated settlement primitive for Filecoin Onchain Cloud: a small Filecoin Pay validator that releases a storage provider's payment only for the periods it cryptographically proved it still held the data, and withholds payment for periods it faulted — decided entirely from Filecoin's own on-chain proof record, with no middleman ever holding the money.
Built for Filecoin ProPGF Batch 3, Area 4 (FIL value accrual), mapped to 2026 Network Objective #2 (Strengthen Network Profitability & Cryptoeconomics). Open-source (MIT), deployed and verified live on the Calibration testnet.
Testnet proof-of-concept. Everything below is real and on-chain on Calibration. Mainnet only after a third-party audit. No real funds move.
Read-only, no key, one command — reproduces the whole thing against the live chain:
node sdk/verify.mjsIt re-derives every dataset's proof state from the raw chain and confirms our reader agrees, quotes a faulted rail (releases 0) and a proving rail (releases full), and reads the on-chain KPI — 7 passed, 0 failed.
| What | Address / hash — click to open on Filfox |
|---|---|
| ProofGateValidator (gate + on-chain KPI) | 0x3110eAdEd51491D85367fDf237551C4D8143102D |
| ProofGateView (proof reader) | 0xeA183410C63257b15Fa0bF7D792cAc8C84f73914 |
| Live withhold — 0 USDFC paid to a faulted provider (rail 17975, dataset 1) | 0x3445bd1364c31af5e37a8106299834bd35eeb32114673eeb88cfa41dd703e9c6 |
# the KPI, straight off the validator:
cast call 0x3110eAdEd51491D85367fDf237551C4D8143102D "totalWithheld()(uint256)" \
--rpc-url https://api.calibration.node.glif.io/rpc/v1
# → 9000000000000000 (0.009 USDFC withheld from a provider that stopped proving)The goal is narrow and practical: let any app, onramp, or storage provider make payment follow proof on Filecoin — "only pay for the periods the data was actually proven" — without re-implementing, from raw on-chain state, the fiddly and easy-to-get-wrong logic that decides when money should and shouldn't move. ProofGate is that logic, as a reusable primitive that compounds across the ecosystem rather than serving a single counterparty.
Filecoin storage is paid storage. A storage provider (SP) is paid to hold your data, and must continuously prove it still does: on a recurring deadline (the proving period, ~2h on Calibration) it answers an unpredictable challenge with a PDP (Proof of Data Possession) proof, recorded on-chain. Miss the deadline with no fresh proof, and the period is faulted — the provider probably went dark.
Filecoin also has an on-chain payment system, Filecoin Pay, which streams money to providers over "rails" (a rail is a standing pipe that drips a fixed rate of USDFC from payer to payee, settled periodically).
The gap: proof and payment don't talk to each other. The proof system knows the provider stopped proving; the payment rail keeps dripping money anyway. So today a provider can stop storing your data and still get paid, unless every application rebuilds the "only pay for proven storage" connector by hand from raw proof records. It's fiddly, easy to get wrong, and when it's wrong, money moves when it shouldn't.
The cryptography and the records already exist on-chain. The missing piece is the trustless connector between them.
Filecoin Pay has a hook — a validator contract it consults during settlement that can reduce, withhold, or veto a payout. And PDP proof state is already on-chain and publicly readable. So the connector is buildable trustlessly: a contract that reads Filecoin's own proof record and tells the rail how much to release. No operator decides who faulted; the chain is the source of truth.
Three properties define ProofGate, and everything else follows:
- Non-custodial. The validator and reader hold no funds, ever. They are pure reads over chain state that return an amount to Filecoin Pay; custody stays in Filecoin Pay.
- Oracle-free. Proof state comes only from the on-chain PDP / Warm Storage record. There is no off-chain feed of "who faulted."
- Per proven period. Settlement pays only the periods that were actually proven, forfeits completed faulted periods, and pauses (does not pay) for an in-progress period until its proof lands. Released-only-when-proven is true epoch by epoch, not an all-or-nothing snapshot.
ProofGate sits between Filecoin's proof record and a Filecoin Pay rail.
Storage provider (runs Curio; posts a PDP proof each proving period)
│
PDP / Warm Storage on-chain proof record
│
FWSS StateView provingDeadline · provenThisPeriod · provenPeriods(id, periodId)
│
ProofLib / ProofGateView per-period classification → Inactive | Open | Proven | Faulted
│
ProofGateValidator Filecoin Pay IValidator: pay proven periods, withhold faulted ones,
record totalReleased / totalWithheld on-chain
│
FilecoinPay.settleRail releases only the proven amount in USDFC
│
payer ⇄ payee money moves only for proven storage
There are three flows, and they are the only three this primitive needs.
Read. proofState(dataSetId) classifies a dataset from raw StateView reads. The single most failure-prone detail in this whole system is the silent-fault rule: a fully-down provider records no fault event until someone pokes nextProvingPeriod — so you must not wait for a FaultRecord. ProofGate treats "deadline passed with no fresh proof" as faulted directly:
proofState = Inactive if provingDeadline == 0
= Proven if provenThisPeriod == true
= Faulted if block.number > provingDeadline // silent-fault aware, no event needed
= Open otherwise (period in progress, not yet due)
This rule lives in exactly one place (ProofLib) and is covered by tests against an independent re-derivation from the raw chain.
Gate. During settleRail, Filecoin Pay calls validatePayment. ProofGate walks the proving periods spanned by the settlement window and pays only the proven ones — using provenPeriods(dataSetId, periodId) where periodId = (epoch − activation − 1) / maxProvingPeriod (matching Warm Storage's own _provingPeriodForEpoch). Completed faulted periods are forfeited only after a configurable dispute window (defending an honest SP against a client-induced transient-fault attack); the in-progress period is paused, never paid before proof. The decision is returned as (modifiedAmount, settleUpto).
Measure. Every real settlement (authenticated by msg.sender == FilecoinPay) updates on-chain counters and emits a SettlementGated event. The KPI — USDFC withheld-on-fault vs released — is therefore summable on a block explorer and readable via getters, not a number we assert off-chain.
Solidity 0.8.24, via_ir, evm_version = paris (Calibration FEVM does not support PUSH0). Non-upgradeable, MIT.
ProofLib — the proof classifier (pure, the trust-minimized heart):
enum ProofState { Inactive, Open, Proven, Faulted }
function proofState(IFWSSStateView sv, uint256 dataSetId) internal view returns (ProofState);
function isFaulted(IFWSSStateView sv, uint256 dataSetId) internal view returns (bool);
function inChallengeWindow(IFWSSStateView sv, uint256 dataSetId) internal view returns (bool);ProofGateView — a deployable read-only reader (for dashboards, other contracts, reviewers):
function proofState(uint256 dataSetId) external view returns (uint8); // 0..3 per ProofState
function isFaulted(uint256 dataSetId) external view returns (bool);
function inChallengeWindow(uint256 dataSetId) external view returns (bool);
function proofStateForRail(uint256 fwssRailId) external view returns (uint8);
function snapshot(uint256 dataSetId) external view
returns (uint8 state, bool faulted, bool inWindow, uint256 deadline, bool provenThisPeriod);ProofGateValidator — the gate + KPI; implements Filecoin Pay's IValidator:
// --- Filecoin Pay hook (called during settleRail) ---
struct ValidationResult { uint256 modifiedAmount; uint256 settleUpto; string note; }
function validatePayment(uint256 railId, uint256 proposedAmount, uint256 fromEpoch, uint256 toEpoch, uint256 rate)
external returns (ValidationResult memory);
function railTerminated(uint256 railId, address terminator, uint256 endEpoch) external; // only the payments contract
// --- immutable binding: a rail is bound to a dataset ONCE and can never be repointed ---
function registerRail(uint256 railId, uint256 dataSetId) external; // onlyOwner, one-time; reverts AlreadyBound
function railDataSet(uint256 railId) external view returns (uint256);
function bound(uint256 railId) external view returns (bool);
// --- read-only gating preview (what a settlement WOULD do, no tx) ---
function quote(uint256 railId, uint256 fromEpoch, uint256 toEpoch, uint256 rate)
external view returns (uint256 released, uint256 settleUpto, uint256 withheld);
function preview(uint256 railId) external view returns (uint8 state, uint256 released, uint256 withheld);
// --- on-chain KPI (the verification surface) ---
function totalReleased() external view returns (uint256);
function totalWithheld() external view returns (uint256);
function railReleased(uint256 railId) external view returns (uint256);
function railWithheld(uint256 railId) external view returns (uint256);
function disputeWindowEpochs() external view returns (uint256);
event SettlementGated(uint256 indexed railId, uint256 indexed dataSetId,
uint256 proposedAmount, uint256 released, uint256 withheld,
uint256 fromEpoch, uint256 settleUpto);
event RailBound(uint256 indexed railId, uint256 indexed dataSetId);The Warm Storage StateView surface it reads (FilecoinWarmStorageServiceStateView, resolved dynamically off FWSS.viewContractAddress()):
provingDeadline(uint256 setId) → uint256 // 0 = no proving obligation
provenThisPeriod(uint256 dataSetId) → bool
provenPeriods(uint256 dataSetId, uint256 periodId) → bool // per-period bitmap getter
provingActivationEpoch(uint256 dataSetId) → uint256
getPDPConfig() → (uint64 maxProvingPeriod, uint256 challengeWindow, uint256 challengesPerProof, uint256 initChallengeWindowStart)
getCurrentPricingRates() → (uint256 storagePrice, uint256 datasetFee)
railToDataSet(uint256 railId) → uint256| Contract | Address — click to open on Filfox |
|---|---|
| ProofGateValidator (ours) | 0x3110eAdEd51491D85367fDf237551C4D8143102D |
| ProofGateView (ours) | 0xeA183410C63257b15Fa0bF7D792cAc8C84f73914 |
| FilecoinPayV1 (FOC) | 0x09a0fDc2723fAd1A7b8e3e00eE5DF73841df55a0 |
| FWSS StateView (FOC) | 0xF4B446171b3677fD2B9b183a9fB76d517365700a |
| USDFC (rail token) | 0xb3042734b608a1B16e9e86B374A3f3e389B4cDf0 |
| Multicall3 | 0xcA11bde05977b3631167028862bE2a173976CA11 |
Network: Filecoin Calibration, chainId 314159, 30s epochs. Proving period 240 epochs (~2h), challenge window 20 epochs (~10min). Pricing 2.5 USDFC/TiB/month + 0.024 USDFC/month. Explorer URL schema: address → https://calibration.filfox.info/en/address/<addr> · tx → https://calibration.filfox.info/en/message/<0x-txhash>.
sdk/proofgate.mjs — a thin, viem-based helper so an app drives the whole flow in a few lines, over a multi-RPC fallback (no single-counterparty dependency):
makeClients · makePublicClient · ensureDeposit · ensureOperatorApproval
createGatedRail · configureRail · bindRail · settleRail
findProvenActiveDataSet · readKPI · withdraw
sdk/demo.mjs is a phased runner (fund → create → settle → release → withdraw). The architecture is Path A: ProofGate creates its own Filecoin Pay rail with validator = ProofGateValidator and binds it immutably to a dataset, while that dataset is created and proven through the real Warm Storage + PDP path. (FWSS hardcodes itself as validator on its own rails, so gating runs on a parallel rail that reads the real proof state.)
contracts/ Foundry (via_ir, evm_version=paris)
src/
ProofLib.sol per-period proof classifier (the silent-fault rule, in one place)
ProofGateView.sol deployable read-only reader
ProofGateValidator.sol the Filecoin Pay validator: per-period gating, immutable
binding, dispute window, on-chain KPI + SettlementGated event
interfaces/ IValidator (verbatim from Filecoin Pay), IFWSSStateView
test/ProofGate.t.sol live-fork tests vs the real Calibration proof record
script/Deploy.s.sol deploy + write deployments/calibration.json
sdk/
proofgate.mjs viem helper (multi-RPC), abis.mjs
demo.mjs phased live runner
verify.mjs READ-ONLY reproducer — no key; the "run it yourself" artifact
app/ Vite + React + viem dashboard: live network proving-health (Multicall3)
+ the KPI read straight off the validator on-chain
docs: CLAUDE.md (build source of truth) · PROOFGATE-DEMO-STATUS.md (full technical status)
· PROOFGATE-DESIGN-BRIEF.md · PROOFGATE-GRANT-BRIEF.md
ProofGate builds on the Filecoin Onchain Cloud stack rather than competing with it.
- Filecoin Pay (FilOzone) is the integration point: ProofGate implements its
IValidatorhook verbatim and attaches atcreateRail. ItsvalidatePaymentis a side-effect-freeviewthat records the KPI only on the authenticated caller path — the correct posture after thefilecoin-services#300/ PR#301 fix removed the un-authenticatedPaymentArbitratedevent (read settlement truth from state, not events). - Warm Storage / PDP (FilOzone) is the proof record. ProofGate reads it dynamically (
viewContractAddress()) rather than hardcoding, because these are upgradeable beta proxies. The per-period classification mirrors Warm Storage's own_provingPeriodForEpochandprovenPeriodsbitmap. - Synapse SDK (FilOzone) is how a real dataset is created and proven; ProofGate reuses that path and gates a parallel payment rail on its proof state.
The withhold path runs end-to-end on live Calibration and is recorded on-chain:
- Rail 17975, bound immutably to genuinely-faulted dataset #1, settled 0 USDFC to the payee →
totalWithheld() == 9000000000000000(0.009 USDFC). Tx0x3445bd…03e9c6(filfox), status success, block 3809339.
The release path is verified on-chain via quote() — a read that returns the full amount for a proving dataset and 0 for a faulted one — reproduced by verify.mjs against whichever dataset is in its proving window at run time.
forge test runs the same classification as live-fork tests against the real proof record; node sdk/verify.mjs reproduces both gating directions and the KPI read-only from the chain.
Everything the proof-gating core sets out to do is implemented, deployed, and — where it touches money — verified on-chain. The contracts are checked against an independent reference (a raw re-derivation of proof state from the chain, not against the implementation itself), and the KPI is an on-chain counter, not an assertion.
- The core —
ProofLibclassifier (silent-fault aware),ProofGateViewreader, andProofGateValidatorwith per-period settlement, immutable rail↔dataset binding, a non-zero dispute window, and an on-chain KPI (counters +SettlementGatedevent). Deployed;verify.mjsgreen 7/7. - The live withhold — settled on-chain,
totalWithheld = 0.009 USDFC, with a tx hash anyone can open. - The SDK + dashboard — the few-line helper (multi-RPC) and a live proving-health dashboard that reads the network's proof state and the validator's KPI directly.
What remains is not the gating logic. A fully-live release-and-pay settle is gated by Filecoin Pay's rate accrual plus the dataset's brief (~10 min per ~2h) proven window; demonstrating it on one dataset that visibly faults then recovers needs a controlled storage provider with a short proving period — scoped as a funded milestone, not hidden. The optional "Ballast" tier (a returnable, bounded FIL bond an SP posts, parametrically paid to a harmed client on a confirmed fault) is the project's literal FIL-lock; it holds funds, so it is testnet-only, strictly isolated from the non-custodial core, and explicitly "mainnet only after a third-party audit." It is intentionally not built into the PoC.
Requires Foundry, Node 20+, and an RPC (Glif Calibration is the default).
# contracts
cd contracts
forge build
forge test # live-fork tests vs the real Calibration proof record
forge script script/Deploy.s.sol:Deploy --rpc-url calibration --broadcast \
--private-key $PK --gas-estimate-multiplier 50000 # Filecoin needs a high gas limit
# verifier (read-only, no key) — reproduces proofState, both gating directions, and the KPI
cd ../sdk && npm install && node verify.mjs
# live demo runner (needs a funded Calibration wallet in contracts/.env as PK=0x…)
node demo.mjs fund && node demo.mjs create && node demo.mjs settle
# dashboard
cd ../app && npm install && npm run dev # http://localhost:5273contracts/.env (with the deploy key) is gitignored and never published; the demo wallet is a throwaway testnet key.
MIT.