From 9c895284d53794e04eb625a2bf6280b62fc84651 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Thu, 19 Mar 2026 17:07:57 +0000 Subject: [PATCH 1/8] adding griefing vectors for FOC (WIP) --- docker-compose.yaml | 12 +- workload/cmd/foc-sidecar/assertions.go | 5 +- workload/cmd/stress-engine/main.go | 5 + workload/cmd/stress-engine/sybil_vectors.go | 432 ++++++++++++++++++++ workload/entrypoint/entrypoint.sh | 4 +- workload/internal/foc/eth.go | 119 ++++++ 6 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 workload/cmd/stress-engine/sybil_vectors.go diff --git a/docker-compose.yaml b/docker-compose.yaml index 305e0d2a..0842b863 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -89,18 +89,18 @@ services: <<: *healthcheck_settings test: curl --fail http://lotus1:1234/health/livez - forest0: - <<: [ *filecoin_service, *needs_lotus0_healthy ] - image: forest:latest - container_name: forest0 - entrypoint: [ "./scripts/start-forest.sh", "0" ] + # forest0: + # <<: [ *filecoin_service, *needs_lotus0_healthy ] + # image: forest:latest + # container_name: forest0 + # entrypoint: [ "./scripts/start-forest.sh", "0" ] workload: <<: [ *filecoin_service ] image: workload:latest container_name: workload environment: - - STRESS_NODES=lotus0,lotus1,forest0 + - STRESS_NODES=lotus0,lotus1 - STRESS_RPC_PORT=1234 - STRESS_FOREST_RPC_PORT=3456 - STRESS_KEYSTORE_PATH=/shared/configs/stress_keystore.json diff --git a/workload/cmd/foc-sidecar/assertions.go b/workload/cmd/foc-sidecar/assertions.go index 62ca1e70..28e259f0 100644 --- a/workload/cmd/foc-sidecar/assertions.go +++ b/workload/cmd/foc-sidecar/assertions.go @@ -79,12 +79,13 @@ func checkFilecoinPaySolvency(ctx context.Context, node api.FullNode, cfg *foc.C return } + // Sum only funds (not funds + lockup). The lockupCurrent field is a + // subset of funds — it represents the locked portion, not additional + // balance. Adding both double-counts and causes false solvency violations. totalOwed := new(big.Int) for _, payer := range payers { funds := foc.ReadAccountFunds(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, payer) - lockup := foc.ReadAccountLockup(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, payer) totalOwed.Add(totalOwed, funds) - totalOwed.Add(totalOwed, lockup) } solvent := filPayBalance.Cmp(totalOwed) >= 0 diff --git a/workload/cmd/stress-engine/main.go b/workload/cmd/stress-engine/main.go index 4b52578f..4dedadba 100644 --- a/workload/cmd/stress-engine/main.go +++ b/workload/cmd/stress-engine/main.go @@ -308,6 +308,10 @@ func buildDeck() { // Destructive — weight 0 by default (opt-in) weightedAction{"DoFOCDeletePiece", "STRESS_WEIGHT_FOC_DELETE_PIECE", DoFOCDeletePiece, 0}, weightedAction{"DoFOCDeleteDataSet", "STRESS_WEIGHT_FOC_DELETE_DS", DoFOCDeleteDataSet, 0}, + // PDP payment accounting (fee extraction verification) + weightedAction{"DoPDPPaymentAccounting", "STRESS_WEIGHT_PDP_ACCOUNTING", DoPDPPaymentAccounting, 2}, + // Cross-node receipt consistency (catches consensus divergence on EVM txs) + weightedAction{"DoReceiptAudit", "STRESS_WEIGHT_RECEIPT_AUDIT", DoReceiptAudit, 1}, ) } @@ -378,6 +382,7 @@ func main() { } if focCfg != nil { logFOCProgress() + logGriefProgress() } } } diff --git a/workload/cmd/stress-engine/sybil_vectors.go b/workload/cmd/stress-engine/sybil_vectors.go new file mode 100644 index 00000000..e4973428 --- /dev/null +++ b/workload/cmd/stress-engine/sybil_vectors.go @@ -0,0 +1,432 @@ +package main + +import ( + "encoding/hex" + "log" + "math/big" + "sync" + + "workload/internal/foc" + + "github.com/antithesishq/antithesis-sdk-go/assert" + "github.com/antithesishq/antithesis-sdk-go/random" + filbig "github.com/filecoin-project/go-state-types/big" +) + +// =========================================================================== +// PDP Payment Accounting Vectors +// +// Exercises the dataset creation payment flow with a secondary client wallet +// that has a minimal USDFC balance. Verifies that payment rails correctly +// deduct fees from the client on dataset creation. +// +// The core invariant: after a confirmed dataset creation, the client's +// available USDFC in FilecoinPayV1 should decrease (fee extraction working). +// =========================================================================== + +const griefUSDFCDeposit = 60000000000000000 // 0.06 USDFC (18 decimals) + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +type griefState int + +const ( + griefInit griefState = iota + griefFunded // USDFC transferred to secondary client + griefActorCreated // f4 actor exists on-chain (received native FIL) + griefApproved // secondary client approved FPV1 to spend USDFC + griefDeposited // secondary client deposited USDFC into FPV1 + griefOperatorOK // secondary client approved FWSS as operator + griefReady // ready to exercise payment flows +) + +func (s griefState) String() string { + switch s { + case griefInit: + return "Init" + case griefFunded: + return "Funded" + case griefActorCreated: + return "ActorCreated" + case griefApproved: + return "Approved" + case griefDeposited: + return "Deposited" + case griefOperatorOK: + return "OperatorApproved" + case griefReady: + return "Ready" + default: + return "Unknown" + } +} + +var ( + griefRT griefRuntime + griefMu sync.Mutex +) + +type griefRuntime struct { + State griefState + ClientKey []byte // 32-byte secp256k1 private key (secondary client) + ClientEth []byte // 20-byte ETH address + InitFunds *big.Int // FPV1 funds snapshot after deposit + DSCreated int + LastFunds *big.Int +} + +func griefSnap() griefRuntime { + griefMu.Lock() + defer griefMu.Unlock() + return griefRT +} + +// --------------------------------------------------------------------------- +// DoPDPPaymentAccounting — single vector, two phases +// --------------------------------------------------------------------------- + +func DoPDPPaymentAccounting() { + if focCfg == nil || focCfg.ClientKey == nil { + return + } + + // Wait for FOC lifecycle to be ready (contract addresses available) + if _, ok := requireReady(); !ok { + return + } + + // Ensure required addresses are available + if focCfg.USDFCAddr == nil || focCfg.FilPayAddr == nil || focCfg.FWSSAddr == nil { + return + } + + griefMu.Lock() + currentState := griefRT.State + griefMu.Unlock() + + switch currentState { + case griefInit: + doGriefInit() + case griefFunded: + doGriefCreateActor() + case griefActorCreated: + doGriefApprove() + case griefApproved: + doGriefDeposit() + case griefDeposited: + doGriefApproveOperator() + case griefOperatorOK: + doGriefArm() + case griefReady: + doGriefProbe() + } +} + +// --------------------------------------------------------------------------- +// Setup Steps +// --------------------------------------------------------------------------- + +// doGriefInit picks the secondary client wallet and transfers USDFC from the primary client. +func doGriefInit() { + if len(addrs) < 2 { + log.Printf("[sybil-fee-grief] not enough wallets in keystore") + return + } + + // Pick last wallet as dedicated secondary client and remove it from + // the general pool so pickWallet() never selects it (avoids nonce collisions). + griefMu.Lock() + if griefRT.ClientKey == nil { + addr := addrs[len(addrs)-1] + ki := keystore[addr] + griefRT.ClientKey = ki.PrivateKey + griefRT.ClientEth = foc.DeriveEthAddr(ki.PrivateKey) + addrs = addrs[:len(addrs)-1] + log.Printf("[sybil-fee-grief] secondary client: filAddr=%s ethAddr=0x%x (removed from wallet pool)", addr, griefRT.ClientEth) + } + clientEth := griefRT.ClientEth + griefMu.Unlock() + + if clientEth == nil { + log.Printf("[sybil-fee-grief] failed to derive secondary client ETH address") + return + } + + node := focNode() + + // Transfer 0.06 USDFC from primary client to secondary client + amount := big.NewInt(griefUSDFCDeposit) + calldata := foc.BuildCalldata(foc.SigTransfer, + foc.EncodeAddress(clientEth), + foc.EncodeBigInt(amount), + ) + + log.Printf("[sybil-fee-grief] state=Init → funding secondary client with USDFC") + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, calldata, "pdp-acct-fund") + if !ok { + log.Printf("[sybil-fee-grief] USDFC transfer failed, will retry") + return + } + + log.Printf("[sybil-fee-grief] secondary client funded") + + griefMu.Lock() + griefRT.State = griefFunded + griefMu.Unlock() +} + +// doGriefCreateActor sends a small FIL transfer via EVM from the FOC client +// to the secondary client's ETH address, creating the f4 actor on-chain. +// Without this, EVM transactions from the secondary client fail with +// "actor not found". Uses the FOC client (which already has an f4 actor and +// FIL) to send the transaction. +func doGriefCreateActor() { + s := griefSnap() + node := focNode() + + log.Printf("[pdp-accounting] state=Funded → creating f4 actor via EVM transfer") + + // Send 0.001 FIL from FOC client to secondary client's ETH address. + // This creates the f4 actor as a side effect of the EVM value transfer. + oneMilliFIL := filbig.NewInt(1_000_000_000_000_000) // 0.001 FIL + ok := foc.SendEthTxConfirmedWithValue(ctx, node, focCfg.ClientKey, s.ClientEth, oneMilliFIL, "pdp-acct-f4") + if !ok { + log.Printf("[pdp-accounting] f4 actor creation failed, will retry") + return + } + + log.Printf("[pdp-accounting] f4 actor created for ethAddr=0x%x", s.ClientEth) + + griefMu.Lock() + griefRT.State = griefActorCreated + griefMu.Unlock() +} + +// doGriefApprove has the secondary client approve FPV1 to spend USDFC. +func doGriefApprove() { + s := griefSnap() + node := focNode() + + maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + calldata := foc.BuildCalldata(foc.SigApprove, + foc.EncodeAddress(focCfg.FilPayAddr), + foc.EncodeBigInt(maxUint256), + ) + + log.Printf("[sybil-fee-grief] state=ActorCreated → approving FPV1") + ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.USDFCAddr, calldata, "pdp-acct-approve") + if !ok { + log.Printf("[sybil-fee-grief] approve failed, will retry") + return + } + + log.Printf("[sybil-fee-grief] FPV1 approved") + + griefMu.Lock() + griefRT.State = griefApproved + griefMu.Unlock() +} + +// doGriefDeposit deposits USDFC into FPV1 for the secondary client. +func doGriefDeposit() { + s := griefSnap() + node := focNode() + + amount := big.NewInt(griefUSDFCDeposit) + calldata := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(amount), + ) + + log.Printf("[sybil-fee-grief] state=Approved → depositing USDFC into FPV1") + ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-acct-deposit") + if !ok { + log.Printf("[sybil-fee-grief] deposit failed, will retry") + return + } + + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + log.Printf("[sybil-fee-grief] FPV1 funds after deposit: %s", funds) + + griefMu.Lock() + griefRT.State = griefDeposited + griefMu.Unlock() +} + +// doGriefApproveOperator approves FWSS as operator for the secondary client on FPV1. +func doGriefApproveOperator() { + s := griefSnap() + node := focNode() + + maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + maxLockupPeriod := big.NewInt(86400) + + calldata := foc.BuildCalldata(foc.SigSetOpApproval, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(focCfg.FWSSAddr), + foc.EncodeBool(true), + foc.EncodeBigInt(maxUint256), + foc.EncodeBigInt(maxUint256), + foc.EncodeBigInt(maxLockupPeriod), + ) + + log.Printf("[sybil-fee-grief] state=Deposited → approving FWSS as operator") + ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-acct-op") + if !ok { + log.Printf("[sybil-fee-grief] operator approval failed, will retry") + return + } + + log.Printf("[sybil-fee-grief] FWSS operator approved") + + griefMu.Lock() + griefRT.State = griefOperatorOK + griefMu.Unlock() +} + +// doGriefArm snapshots initial funds and transitions to Ready. +func doGriefArm() { + s := griefSnap() + node := focNode() + + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + + log.Printf("[sybil-fee-grief] state=OperatorApproved → ready. initialFunds=%s", funds) + assert.Sometimes(true, "PDP secondary client setup completes", map[string]any{ + "initialFunds": funds.String(), + }) + + griefMu.Lock() + griefRT.InitFunds = funds + griefRT.State = griefReady + griefMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Steady State — Payment Accounting Probe +// --------------------------------------------------------------------------- + +// doGriefProbe creates an empty dataset via Curio HTTP and verifies that the +// client's USDFC balance in FPV1 decreases (payment rails extract fees correctly). +func doGriefProbe() { + if !foc.PingCurio(ctx) { + log.Printf("[sybil-fee-grief] curio not reachable, skipping") + return + } + + // Ensure SP key is loaded (needed for EIP-712 payee) + if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + log.Printf("[sybil-fee-grief] SP key not available, skipping") + return + } + } + + s := griefSnap() + node := focNode() + + // 1. Snapshot client FPV1 funds BEFORE + fundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + if fundsBefore == nil || fundsBefore.Sign() == 0 { + log.Printf("[sybil-fee-grief] client funds exhausted (%v), skipping", fundsBefore) + return + } + + // 2. Build dataset creation request (empty dataset, payer = secondary client) + clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) + metadataKeys := []string{"source"} + metadataValues := []string{"antithesis-stress"} + payee := focCfg.SPEthAddr + + sig, err := foc.SignEIP712CreateDataSet( + s.ClientKey, focCfg.FWSSAddr, + clientDataSetId, payee, + metadataKeys, metadataValues, + ) + if err != nil { + log.Printf("[sybil-fee-grief] EIP-712 signing failed: %v", err) + return + } + + extraData := encodeCreateDataSetExtra(s.ClientEth, clientDataSetId, metadataKeys, metadataValues, sig) + recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) + + // 3. Submit via Curio HTTP API + log.Printf("[sybil-fee-grief] creating dataset: clientDataSetId=%s", clientDataSetId) + txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) + if err != nil { + log.Printf("[sybil-fee-grief] CreateDataSetHTTP failed: %v", err) + return + } + + // 4. Wait for on-chain confirmation + onChainID, err := foc.WaitForDataSetCreation(ctx, txHash) + if err != nil { + log.Printf("[sybil-fee-grief] WaitForDataSetCreation failed: %v", err) + return + } + + // 5. Snapshot client FPV1 funds AFTER + fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + + // 6. Invariant: payment rails should deduct fees from client on dataset creation + fundsDecreased := fundsAfter.Cmp(fundsBefore) < 0 + delta := new(big.Int).Sub(fundsBefore, fundsAfter) + + assert.Sometimes(fundsDecreased, + "dataset creation fee deducted from client USDFC", + map[string]any{ + "fundsBefore": fundsBefore.String(), + "fundsAfter": fundsAfter.String(), + "delta": delta.String(), + "onChainID": onChainID, + "fundsDecreased": fundsDecreased, + }) + + griefMu.Lock() + griefRT.DSCreated++ + griefRT.LastFunds = fundsAfter + created := griefRT.DSCreated + griefMu.Unlock() + + log.Printf("[sybil-fee-grief] dataset created: onChainID=%d fundsBefore=%s fundsAfter=%s delta=%s decreased=%v total=%d", + onChainID, fundsBefore, fundsAfter, delta, fundsDecreased, created) + + // 7. Observational: log SP balance + logGriefSPBalance() +} + +// logGriefSPBalance logs the SP's FIL balance for observational purposes. +func logGriefSPBalance() { + if focCfg.SPKey == nil { + return + } + spFilAddr, err := foc.DeriveFilAddr(focCfg.SPKey) + if err != nil { + return + } + bal, err := focNode().WalletBalance(ctx, spFilAddr) + if err != nil { + return + } + + s := griefSnap() + log.Printf("[sybil-fee-grief] SP balance=%s datasetsCreated=%d", bal, s.DSCreated) +} + +// --------------------------------------------------------------------------- +// Progress +// --------------------------------------------------------------------------- + +func logGriefProgress() { + s := griefSnap() + if s.ClientEth == nil { + return + } + log.Printf("[sybil-fee-grief] state=%s datasetsCreated=%d initFunds=%v lastFunds=%v", + s.State, s.DSCreated, s.InitFunds, s.LastFunds) +} diff --git a/workload/entrypoint/entrypoint.sh b/workload/entrypoint/entrypoint.sh index f4873e87..03d1ae46 100755 --- a/workload/entrypoint/entrypoint.sh +++ b/workload/entrypoint/entrypoint.sh @@ -77,7 +77,9 @@ log_info "Launching stress engine..." /opt/antithesis/stress-engine & STRESS_PID=$! -if [ "${FUZZER_ENABLED:-1}" = "1" ]; then +if getent hosts filwizard &>/dev/null; then + log_info "FOC profile — skipping protocol fuzzer (wrong layer, adds noise)" +elif [ "${FUZZER_ENABLED:-1}" = "1" ]; then log_info "Launching protocol fuzzer..." /opt/antithesis/protocol-fuzzer & FUZZER_PID=$! diff --git a/workload/internal/foc/eth.go b/workload/internal/foc/eth.go index 87ea3142..6229616a 100644 --- a/workload/internal/foc/eth.go +++ b/workload/internal/foc/eth.go @@ -125,6 +125,125 @@ func sendEthTxCore(ctx context.Context, node api.FullNode, privKey []byte, toAdd return txHash, true } +// SendEthTxConfirmedWithValue signs, submits, and waits for an EVM transaction +// that includes a FIL value transfer. Used to create f4 actors by sending a +// small amount of FIL to an ETH address. +func SendEthTxConfirmedWithValue(ctx context.Context, node api.FullNode, privKey []byte, toAddr []byte, value filbig.Int, tag string) bool { + txHash, ok := sendEthTxCoreWithValue(ctx, node, privKey, toAddr, nil, value, tag) + if !ok { + return false + } + + deadline := time.Now().Add(receiptPollTimeout) + for time.Now().Before(deadline) { + receipt, err := node.EthGetTransactionReceipt(ctx, txHash) + if err != nil || receipt == nil { + time.Sleep(receiptPollInterval) + continue + } + + log.Printf("[%s] tx %s receipt: status=%d gasUsed=%d blockNum=%v", tag, txHash, receipt.Status, receipt.GasUsed, receipt.BlockNumber) + if receipt.Status == 0 { + log.Printf("[%s] tx %s REVERTED gasUsed=%d", tag, txHash, receipt.GasUsed) + return false + } + return true + } + + log.Printf("[%s] tx %s receipt timeout after %v — invalidating nonce cache", tag, txHash, receiptPollTimeout) + if senderAddr, err := DeriveFilAddr(privKey); err == nil { + EthNoncesMu.Lock() + delete(EthNonces, senderAddr) + EthNoncesMu.Unlock() + } + return false +} + +// sendEthTxCoreWithValue is like sendEthTxCore but allows a non-zero msg.value. +func sendEthTxCoreWithValue(ctx context.Context, node api.FullNode, privKey []byte, toAddr []byte, calldata []byte, value filbig.Int, tag string) (ethtypes.EthHash, bool) { + var zero ethtypes.EthHash + + if len(privKey) != 32 { + log.Printf("[%s] invalid private key length %d", tag, len(privKey)) + return zero, false + } + + senderAddr, err := DeriveFilAddr(privKey) + if err != nil { + log.Printf("[%s] DeriveFilAddr failed: %v", tag, err) + return zero, false + } + + EthNoncesMu.Lock() + nonce, known := EthNonces[senderAddr] + if !known { + n, err := node.MpoolGetNonce(ctx, senderAddr) + if err != nil { + EthNoncesMu.Unlock() + log.Printf("[%s] MpoolGetNonce failed: %v", tag, err) + return zero, false + } + nonce = n + } + EthNonces[senderAddr] = nonce + 1 + EthNoncesMu.Unlock() + + toEth, err := ethtypes.CastEthAddress(toAddr) + if err != nil { + log.Printf("[%s] CastEthAddress failed: %v", tag, err) + return zero, false + } + + tx := ethtypes.Eth1559TxArgs{ + ChainID: 31415926, + Nonce: int(nonce), + To: &toEth, + Value: value, + MaxFeePerGas: types.NanoFil, + MaxPriorityFeePerGas: filbig.NewInt(100), + GasLimit: 30_000_000, + Input: calldata, + V: filbig.Zero(), + R: filbig.Zero(), + S: filbig.Zero(), + } + + preimage, err := tx.ToRlpUnsignedMsg() + if err != nil { + log.Printf("[%s] ToRlpUnsignedMsg failed: %v", tag, err) + return zero, false + } + + sig, err := sigs.Sign(crypto.SigTypeDelegated, privKey, preimage) + if err != nil { + log.Printf("[%s] sigs.Sign failed: %v", tag, err) + return zero, false + } + + if err := tx.InitialiseSignature(*sig); err != nil { + log.Printf("[%s] InitialiseSignature failed: %v", tag, err) + return zero, false + } + + signed, err := tx.ToRlpSignedMsg() + if err != nil { + log.Printf("[%s] ToRlpSignedMsg failed: %v", tag, err) + return zero, false + } + + txHash, err := node.EthSendRawTransaction(ctx, signed) + if err != nil { + log.Printf("[%s] EthSendRawTransaction failed: %v", tag, err) + EthNoncesMu.Lock() + delete(EthNonces, senderAddr) + EthNoncesMu.Unlock() + return zero, false + } + + log.Printf("[%s] tx submitted: from=%s nonce=%d to=%x value=%s txHash=%s", tag, senderAddr, nonce, toAddr, value, txHash) + return txHash, true +} + // SendEthTx signs and submits an EIP-1559 EVM transaction via EthSendRawTransaction. // Returns true if the transaction was accepted by the mempool. func SendEthTx(ctx context.Context, node api.FullNode, privKey []byte, toAddr []byte, calldata []byte, tag string) bool { From 9db219db46434bccecbb3e33f6414060b58ecd91 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Mon, 23 Mar 2026 15:16:09 +0000 Subject: [PATCH 2/8] FOC pdp greifing workload --- filwizard/Dockerfile | 5 + filwizard/patches/fix-pdp-deploy.sh | 36 ++ .../{sybil_vectors.go => griefing_vectors.go} | 338 +++++++++++++++++- workload/cmd/stress-engine/main.go | 4 +- 4 files changed, 371 insertions(+), 12 deletions(-) create mode 100755 filwizard/patches/fix-pdp-deploy.sh rename workload/cmd/stress-engine/{sybil_vectors.go => griefing_vectors.go} (50%) diff --git a/filwizard/Dockerfile b/filwizard/Dockerfile index 4a8b8578..d0558384 100644 --- a/filwizard/Dockerfile +++ b/filwizard/Dockerfile @@ -44,6 +44,11 @@ RUN filwizard contract clone-config \ --config /opt/filwizard/config/filecoin-synapse.json \ --workspace /opt/filwizard/workspace +# Patch: PDPVerifier v3.2.0 constructor needs 4 args, upstream deploy script passes 1 +COPY patches/fix-pdp-deploy.sh /tmp/fix-pdp-deploy.sh +RUN chmod +x /tmp/fix-pdp-deploy.sh && \ + /tmp/fix-pdp-deploy.sh /opt/filwizard/workspace/filecoinwarmstorage/service_contracts/tools/warm-storage-deploy-all.sh && \ + rm /tmp/fix-pdp-deploy.sh # Copy scripts COPY scripts/register-service-provider.js /opt/filwizard/scripts/register-service-provider.js diff --git a/filwizard/patches/fix-pdp-deploy.sh b/filwizard/patches/fix-pdp-deploy.sh new file mode 100755 index 00000000..893c0936 --- /dev/null +++ b/filwizard/patches/fix-pdp-deploy.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Patches warm-storage-deploy-all.sh for PDPVerifier v3.2.0 constructor +# The upstream script passes 1 arg but the constructor now needs 4: +# (uint64 _initializerVersion, address _usdfcTokenAddress, uint256 _usdfcSybilFee, address _paymentsContractAddress) +# Fix: deploy FilecoinPayV1 before PDPVerifier, then pass all 4 constructor args. + +DEPLOY_SCRIPT="$1" + +if [ -z "$DEPLOY_SCRIPT" ] || [ ! -f "$DEPLOY_SCRIPT" ]; then + echo "Usage: $0 " + exit 1 +fi + +# 1. Replace the PDPVerifier constructor args line (1 arg → 4 args) +# Before: $PDP_INIT_COUNTER +# After: $PDP_INIT_COUNTER ${USDFC_TOKEN_ADDRESS:-0x0000000000000000000000000000000000000000} ${USDFC_SYBIL_FEE:-60000000000000000} ${FILECOIN_PAY_ADDRESS:-0x0000000000000000000000000000000000000000} +sed -i 's|"PDPVerifier implementation" \\$|"PDPVerifier implementation" \\|' "$DEPLOY_SCRIPT" +sed -i 's| \$PDP_INIT_COUNTER$| $PDP_INIT_COUNTER ${USDFC_TOKEN_ADDRESS:-0x0000000000000000000000000000000000000000} ${USDFC_SYBIL_FEE:-60000000000000000} ${FILECOIN_PAY_ADDRESS:-0x0000000000000000000000000000000000000000}|' "$DEPLOY_SCRIPT" + +# 2. Move FilecoinPayV1 deployment (Step 3) before PDPVerifier (Step 1) +# Insert FilecoinPayV1 deploy block right after SessionKeyRegistry (Step 0) +# and remove the original Step 3 block +sed -i '/^# Step 0: Deploy or use existing SessionKeyRegistry/,/^deploy_session_key_registry_if_needed/{ + /^deploy_session_key_registry_if_needed/a\ +\ +# Step 0.5: Deploy FilecoinPayV1 early (needed for PDPVerifier constructor)\ +deploy_implementation_if_needed \\\ + "FILECOIN_PAY_ADDRESS" \\\ + "lib/fws-payments/src/FilecoinPayV1.sol:FilecoinPayV1" \\\ + "FilecoinPayV1" +}' "$DEPLOY_SCRIPT" + +# Remove the original Step 3 FilecoinPayV1 block (now redundant — deploy_implementation_if_needed skips if already set) +# No need to remove — the function checks if FILECOIN_PAY_ADDRESS is already set and skips. + +echo "Patched $DEPLOY_SCRIPT for PDPVerifier v3.2.0 constructor" diff --git a/workload/cmd/stress-engine/sybil_vectors.go b/workload/cmd/stress-engine/griefing_vectors.go similarity index 50% rename from workload/cmd/stress-engine/sybil_vectors.go rename to workload/cmd/stress-engine/griefing_vectors.go index e4973428..fb02a8ed 100644 --- a/workload/cmd/stress-engine/sybil_vectors.go +++ b/workload/cmd/stress-engine/griefing_vectors.go @@ -75,6 +75,15 @@ type griefRuntime struct { InitFunds *big.Int // FPV1 funds snapshot after deposit DSCreated int LastFunds *big.Int + + // Extended state for additional probes + LastOnChainDSID int // most recent on-chain dataset ID + LastClientDSID *big.Int // most recent clientDataSetId (for EIP-712) + TermPending bool // terminateService called, waiting for delete + DeletedCount int + SettledCount int + WithdrawCount int + UploadSessions int } func griefSnap() griefRuntime { @@ -84,10 +93,10 @@ func griefSnap() griefRuntime { } // --------------------------------------------------------------------------- -// DoPDPPaymentAccounting — single vector, two phases +// DoPDPGriefingProbe — single vector, two phases // --------------------------------------------------------------------------- -func DoPDPPaymentAccounting() { +func DoPDPGriefingProbe() { if focCfg == nil || focCfg.ClientKey == nil { return } @@ -120,7 +129,7 @@ func DoPDPPaymentAccounting() { case griefOperatorOK: doGriefArm() case griefReady: - doGriefProbe() + doGriefDispatch() } } @@ -306,12 +315,32 @@ func doGriefArm() { } // --------------------------------------------------------------------------- -// Steady State — Payment Accounting Probe +// Steady State — Probe Dispatcher +// --------------------------------------------------------------------------- + +func doGriefDispatch() { + type probe struct { + name string + fn func() + } + probes := []probe{ + {"EmptyDatasetFee", probeEmptyDatasetFee}, + {"InsolvencyCreation", probeInsolvencyCreation}, + {"CrossPayerReplay", probeCrossPayerReplay}, + {"BurstCreation", probeBurstCreation}, + } + pick := probes[rngIntn(len(probes))] + log.Printf("[pdp-griefing] dispatching: %s", pick.name) + pick.fn() +} + +// --------------------------------------------------------------------------- +// Probe 1: Empty Dataset Fee Extraction // --------------------------------------------------------------------------- -// doGriefProbe creates an empty dataset via Curio HTTP and verifies that the -// client's USDFC balance in FPV1 decreases (payment rails extract fees correctly). -func doGriefProbe() { +// probeEmptyDatasetFee creates an empty dataset via Curio HTTP and verifies +// that the client's USDFC balance in FPV1 decreases (fee extraction working). +func probeEmptyDatasetFee() { if !foc.PingCurio(ctx) { log.Printf("[sybil-fee-grief] curio not reachable, skipping") return @@ -390,13 +419,14 @@ func doGriefProbe() { griefMu.Lock() griefRT.DSCreated++ griefRT.LastFunds = fundsAfter + griefRT.LastOnChainDSID = onChainID + griefRT.LastClientDSID = clientDataSetId created := griefRT.DSCreated griefMu.Unlock() - log.Printf("[sybil-fee-grief] dataset created: onChainID=%d fundsBefore=%s fundsAfter=%s delta=%s decreased=%v total=%d", + log.Printf("[pdp-griefing] dataset created: onChainID=%d fundsBefore=%s fundsAfter=%s delta=%s decreased=%v total=%d", onChainID, fundsBefore, fundsAfter, delta, fundsDecreased, created) - // 7. Observational: log SP balance logGriefSPBalance() } @@ -418,6 +448,294 @@ func logGriefSPBalance() { log.Printf("[sybil-fee-grief] SP balance=%s datasetsCreated=%d", bal, s.DSCreated) } +// --------------------------------------------------------------------------- +// Attack A3: Insolvency Creation +// --------------------------------------------------------------------------- + +// probeInsolvencyCreation drains the secondary client's available USDFC, then +// tries to create a dataset. If creation succeeds with zero available funds, +// the SP gets a dataset with no payment guarantee — critical economic bug. +func probeInsolvencyCreation() { + if !foc.PingCurio(ctx) { + return + } + if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + + s := griefSnap() + node := focNode() + + // 1. Read current state + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + if funds == nil || lockup == nil { + return + } + + available := new(big.Int).Sub(funds, lockup) + if available.Sign() <= 0 { + // Already insolvent — try to create + log.Printf("[pdp-griefing] client already insolvent (funds=%s lockup=%s), attempting create", funds, lockup) + } else { + // 2. Drain all available funds + calldata := foc.BuildCalldata(foc.SigWithdraw, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeBigInt(available), + ) + + log.Printf("[pdp-griefing] draining client: withdrawing %s available USDFC", available) + ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-griefing-drain") + if !ok { + log.Printf("[pdp-griefing] withdrawal failed, skipping insolvency test") + return + } + + // Confirm drained + funds = foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + lockup = foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + available = new(big.Int).Sub(funds, lockup) + log.Printf("[pdp-griefing] post-drain: funds=%s lockup=%s available=%s", funds, lockup, available) + } + + // 3. Attempt dataset creation while insolvent + clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) + metadataKeys := []string{"source"} + metadataValues := []string{"antithesis-stress"} + + sig, err := foc.SignEIP712CreateDataSet( + s.ClientKey, focCfg.FWSSAddr, + clientDataSetId, focCfg.SPEthAddr, + metadataKeys, metadataValues, + ) + if err != nil { + log.Printf("[pdp-griefing] EIP-712 signing failed: %v", err) + return + } + + extraData := encodeCreateDataSetExtra(s.ClientEth, clientDataSetId, metadataKeys, metadataValues, sig) + recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) + + log.Printf("[pdp-griefing] attempting dataset creation while insolvent (available=%s)", available) + txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) + + if err != nil { + // HTTP-level rejection — Curio refused to submit the tx + log.Printf("[pdp-griefing] insolvent create rejected at HTTP: %v", err) + assert.Sometimes(true, "insolvent client dataset creation rejected", map[string]any{ + "available": available.String(), + "error": err.Error(), + }) + } else { + // Curio accepted — check if it actually landed on-chain + onChainID, waitErr := foc.WaitForDataSetCreation(ctx, txHash) + if waitErr != nil { + // On-chain revert — correct behavior + log.Printf("[pdp-griefing] insolvent create reverted on-chain: %v", waitErr) + assert.Sometimes(true, "insolvent client dataset creation rejected", map[string]any{ + "available": available.String(), + }) + } else { + // CRITICAL: dataset created with insolvent client + log.Printf("[pdp-griefing] CRITICAL: insolvent client created dataset! onChainID=%d available=%s", onChainID, available) + assert.Sometimes(false, "insolvent client dataset creation rejected", map[string]any{ + "available": available.String(), + "onChainID": onChainID, + "fundsDrain": "client had zero available but creation succeeded", + }) + } + } + + // 4. Re-fund the secondary client for future probes + refundAmount := big.NewInt(griefUSDFCDeposit) + refundCalldata := foc.BuildCalldata(foc.SigTransfer, + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, refundCalldata, "pdp-griefing-refund") + + // Re-deposit into FPV1 + depositCalldata := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, depositCalldata, "pdp-griefing-redeposit") + + log.Printf("[pdp-griefing] secondary client re-funded for future probes") +} + +// --------------------------------------------------------------------------- +// Attack C2: Cross-Payer Signature Replay +// --------------------------------------------------------------------------- + +// probeCrossPayerReplay signs a CreateDataSet EIP-712 message with the +// secondary client key but puts the PRIMARY client's address as the payer +// in the extraData. If the contract doesn't verify signer==payer, the primary +// client gets charged without consenting. +func probeCrossPayerReplay() { + if !foc.PingCurio(ctx) { + return + } + if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + + s := griefSnap() + node := focNode() + + // Read primary client's funds BEFORE + primaryFundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + + // Sign with SECONDARY client key (the attacker) + clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) + metadataKeys := []string{"source"} + metadataValues := []string{"antithesis-stress"} + + sig, err := foc.SignEIP712CreateDataSet( + s.ClientKey, focCfg.FWSSAddr, // signed by attacker + clientDataSetId, focCfg.SPEthAddr, + metadataKeys, metadataValues, + ) + if err != nil { + log.Printf("[pdp-griefing] EIP-712 signing failed: %v", err) + return + } + + // Build extraData with PRIMARY client as payer (not the signer!) + extraData := encodeCreateDataSetExtra(focCfg.ClientEthAddr, clientDataSetId, metadataKeys, metadataValues, sig) + recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) + + log.Printf("[pdp-griefing] attempting cross-payer replay: signer=secondary payer=primary") + txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) + + if err != nil { + // Rejected at HTTP level + log.Printf("[pdp-griefing] cross-payer replay rejected at HTTP: %v", err) + assert.Sometimes(true, "cross-payer signature replay rejected", nil) + return + } + + // Check if it landed on-chain + onChainID, waitErr := foc.WaitForDataSetCreation(ctx, txHash) + if waitErr != nil { + // On-chain revert — correct, signature didn't match payer + log.Printf("[pdp-griefing] cross-payer replay reverted on-chain: %v", waitErr) + assert.Sometimes(true, "cross-payer signature replay rejected", nil) + return + } + + // CRITICAL: creation succeeded — check if primary client was charged + primaryFundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + primaryCharged := primaryFundsAfter.Cmp(primaryFundsBefore) < 0 + + if primaryCharged { + log.Printf("[pdp-griefing] CRITICAL: cross-payer replay succeeded! Primary client charged without signing. onChainID=%d", onChainID) + assert.Sometimes(false, "cross-payer signature replay rejected", map[string]any{ + "onChainID": onChainID, + "primaryFundsBefore": primaryFundsBefore.String(), + "primaryFundsAfter": primaryFundsAfter.String(), + }) + } else { + // Creation succeeded but primary wasn't charged — maybe secondary was? + log.Printf("[pdp-griefing] cross-payer replay: tx succeeded but primary not charged (onChainID=%d)", onChainID) + } +} + +// --------------------------------------------------------------------------- +// Attack D1: Burst Dataset Creation +// --------------------------------------------------------------------------- + +// probeBurstCreation fires multiple dataset creation requests in rapid +// succession without waiting for confirmation. Tests whether Curio rate-limits +// and whether fees are correctly charged under load. +func probeBurstCreation() { + if !foc.PingCurio(ctx) { + return + } + if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + + s := griefSnap() + node := focNode() + + // Check we have funds for the burst + fundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + if fundsBefore == nil || fundsBefore.Sign() == 0 { + return + } + + burstSize := 3 + rngIntn(3) // 3-5 requests + accepted := 0 + recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) + + log.Printf("[pdp-griefing] starting burst creation: %d requests", burstSize) + + for i := 0; i < burstSize; i++ { + clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) + metadataKeys := []string{"source"} + metadataValues := []string{"antithesis-stress"} + + sig, err := foc.SignEIP712CreateDataSet( + s.ClientKey, focCfg.FWSSAddr, + clientDataSetId, focCfg.SPEthAddr, + metadataKeys, metadataValues, + ) + if err != nil { + continue + } + + extraData := encodeCreateDataSetExtra(s.ClientEth, clientDataSetId, metadataKeys, metadataValues, sig) + + // Fire without waiting for confirmation + _, err = foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) + if err != nil { + log.Printf("[pdp-griefing] burst request %d/%d rejected: %v", i+1, burstSize, err) + } else { + accepted++ + } + } + + // Check funds after burst + fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + delta := new(big.Int).Sub(fundsBefore, fundsAfter) + + log.Printf("[pdp-griefing] burst complete: accepted=%d/%d fundsBefore=%s fundsAfter=%s delta=%s", + accepted, burstSize, fundsBefore, fundsAfter, delta) + + // If all requests accepted with no rate limiting, log it + if accepted == burstSize { + assert.Sometimes(true, "burst dataset creation accepted without rate limiting", map[string]any{ + "burstSize": burstSize, + "accepted": accepted, + }) + } + + // Check fees were charged for accepted requests + if accepted > 0 && delta.Sign() > 0 { + assert.Sometimes(true, "burst creation charges fees correctly", map[string]any{ + "accepted": accepted, + "totalDelta": delta.String(), + "fundsBefore": fundsBefore.String(), + }) + } + + griefMu.Lock() + griefRT.DSCreated += accepted + griefRT.LastFunds = fundsAfter + griefMu.Unlock() +} + // --------------------------------------------------------------------------- // Progress // --------------------------------------------------------------------------- @@ -427,6 +745,6 @@ func logGriefProgress() { if s.ClientEth == nil { return } - log.Printf("[sybil-fee-grief] state=%s datasetsCreated=%d initFunds=%v lastFunds=%v", + log.Printf("[pdp-griefing] state=%s ds_created=%d initFunds=%v lastFunds=%v", s.State, s.DSCreated, s.InitFunds, s.LastFunds) } diff --git a/workload/cmd/stress-engine/main.go b/workload/cmd/stress-engine/main.go index 4dedadba..23459c78 100644 --- a/workload/cmd/stress-engine/main.go +++ b/workload/cmd/stress-engine/main.go @@ -308,8 +308,8 @@ func buildDeck() { // Destructive — weight 0 by default (opt-in) weightedAction{"DoFOCDeletePiece", "STRESS_WEIGHT_FOC_DELETE_PIECE", DoFOCDeletePiece, 0}, weightedAction{"DoFOCDeleteDataSet", "STRESS_WEIGHT_FOC_DELETE_DS", DoFOCDeleteDataSet, 0}, - // PDP payment accounting (fee extraction verification) - weightedAction{"DoPDPPaymentAccounting", "STRESS_WEIGHT_PDP_ACCOUNTING", DoPDPPaymentAccounting, 2}, + // PDP griefing and economic assertion probes + weightedAction{"DoPDPGriefingProbe", "STRESS_WEIGHT_PDP_GRIEFING", DoPDPGriefingProbe, 2}, // Cross-node receipt consistency (catches consensus divergence on EVM txs) weightedAction{"DoReceiptAudit", "STRESS_WEIGHT_RECEIPT_AUDIT", DoReceiptAudit, 1}, ) From 310a3881fd341ec5a235b6411b3eff90dac7d70f Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Wed, 25 Mar 2026 08:14:58 +0000 Subject: [PATCH 3/8] Fix griefing vector funding + curio init for multi-miner - Increase secondary client USDFC deposit from 0.06 to 0.5 (minimum lockup + sybilFee = 0.12) - Increase f4 actor gas fund from 0.001 to 1 FIL (EVM txs need ~0.03 FIL) - Fix curio-init miner detection: grep -v t01000|t01001 | tail -1 (handles stale miners) - Fix curio-init temp node layers: seal,post,gui without pdp-only (avoids no-api-keys chicken-and-egg) - Non-fatal pdptool ping during PDP setup (port 80 not available without pdp-only layer) - Griefing weight 8, reorg weight 0 (validate griefing first) --- curio/start_scripts/curio-init.sh | 17 +++++++++-------- docker-compose.yaml | 4 ++-- workload/cmd/stress-engine/griefing_vectors.go | 10 +++++----- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/curio/start_scripts/curio-init.sh b/curio/start_scripts/curio-init.sh index 2e34d0a6..02431270 100755 --- a/curio/start_scripts/curio-init.sh +++ b/curio/start_scripts/curio-init.sh @@ -25,14 +25,15 @@ echo "All ready. Lets go" myip=$(getent hosts curio | awk '{print $1}') # Start a temporary Curio node, wait for its API, then run a callback and stop it. -# Usage: with_temporary_curio +# Usage: with_temporary_curio [layers] with_temporary_curio() { local callback="$1" + local layers="${2:-seal,post,gui}" - echo "Starting temporary Curio node..." - CURIO_FAKE_CPU=5 curio run --nosync --layers seal,post,pdp-only,gui & + echo "Starting temporary Curio node (layers: $layers)..." + CURIO_FAKE_CPU=5 curio run --nosync --layers "$layers" & local curio_pid=$! - sleep 20 + sleep 40 echo "Waiting for Curio API to be ready..." until curio cli --machine "$myip:12300" wait-api; do @@ -80,7 +81,7 @@ if [ ! -f "$CURIO_REPO_PATH/.init.curio" ]; then fi if [ ! -f "$CURIO_REPO_PATH/.init.config" ]; then - newminer=$(lotus state list-miners | grep -E -v 't01000|t01001' | head -1) + newminer=$(lotus state list-miners | grep -v -E 't01000|t01001' | tail -1) echo "New Miner is $newminer" echo "Initiating a new Curio cluster..." @@ -132,7 +133,7 @@ LAYER_EOF curio --version curio cli --machine "$myip:12300" storage attach --init --seal --store "$CURIO_REPO_PATH" } - with_temporary_curio attach_storage + with_temporary_curio attach_storage "seal,post,gui" touch "$CURIO_REPO_PATH/.init.curio" fi @@ -170,9 +171,9 @@ if [ ! -f "$CURIO_REPO_PATH/.init.pdp" ]; then pdptool create-jwt-token pdp | grep -v "JWT Token:" > jwt_token.txt echo "Testing PDP connectivity..." - pdptool ping --service-url http://curio:80 --service-name pdp + pdptool ping --service-url http://curio:80 --service-name pdp || echo "PDP ping skipped (PDP HTTP not running in setup phase, will work after final start)" } - with_temporary_curio setup_pdp + with_temporary_curio setup_pdp "seal,post,gui" touch "$CURIO_REPO_PATH/.init.pdp" echo "PDP service setup complete" diff --git a/docker-compose.yaml b/docker-compose.yaml index a82751e1..7b2356ee 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -143,7 +143,7 @@ services: - STRESS_WEIGHT_HEAD_COMPARISON=5 # cross-node chain head match - STRESS_WEIGHT_STATE_ROOT=6 # cross-node state root match - STRESS_WEIGHT_STATE_AUDIT=5 # full state tree audit - - STRESS_WEIGHT_REORG=2 # power-aware reorg testing + - STRESS_WEIGHT_REORG=0 # disabled during griefing validation, re-enable after - STRESS_WEIGHT_POWER_SLASH=3 # power-aware miner slashing (quorum-safe) - STRESS_WEIGHT_F3_MONITOR=2 # passive F3 health monitor # - STRESS_WEIGHT_QUORUM_STALL=0 # deliberate F3 stall (opt-in, destructive) @@ -197,7 +197,7 @@ services: - STRESS_WEIGHT_FOC_DELETE_DS=0 # delete entire dataset + reset lifecycle # # ADVERSARIAL: economic security + griefing probes - - STRESS_WEIGHT_PDP_GRIEFING=4 # fee extraction, insolvency, replay, burst attacks + - STRESS_WEIGHT_PDP_GRIEFING=8 # fee extraction, insolvency, replay, burst attacks # - CURIO_PDP_URL=http://curio:80 diff --git a/workload/cmd/stress-engine/griefing_vectors.go b/workload/cmd/stress-engine/griefing_vectors.go index fb02a8ed..2153838b 100644 --- a/workload/cmd/stress-engine/griefing_vectors.go +++ b/workload/cmd/stress-engine/griefing_vectors.go @@ -24,7 +24,7 @@ import ( // available USDFC in FilecoinPayV1 should decrease (fee extraction working). // =========================================================================== -const griefUSDFCDeposit = 60000000000000000 // 0.06 USDFC (18 decimals) +const griefUSDFCDeposit = 500000000000000000 // 0.5 USDFC (18 decimals) — must exceed minimumLockup + sybilFee (~0.12 USDFC) // --------------------------------------------------------------------------- // State @@ -197,10 +197,10 @@ func doGriefCreateActor() { log.Printf("[pdp-accounting] state=Funded → creating f4 actor via EVM transfer") - // Send 0.001 FIL from FOC client to secondary client's ETH address. - // This creates the f4 actor as a side effect of the EVM value transfer. - oneMilliFIL := filbig.NewInt(1_000_000_000_000_000) // 0.001 FIL - ok := foc.SendEthTxConfirmedWithValue(ctx, node, focCfg.ClientKey, s.ClientEth, oneMilliFIL, "pdp-acct-f4") + // Send 1 FIL from FOC client to secondary client's ETH address. + // This creates the f4 actor and funds it for gas on subsequent EVM transactions. + gasFund := filbig.NewInt(1_000_000_000_000_000_000) // 1 FIL + ok := foc.SendEthTxConfirmedWithValue(ctx, node, focCfg.ClientKey, s.ClientEth, gasFund, "pdp-acct-f4") if !ok { log.Printf("[pdp-accounting] f4 actor creation failed, will retry") return From bd02a9d678100e24fdb38d267ff91d803ff5c775 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Mon, 6 Apr 2026 17:09:30 +0000 Subject: [PATCH 4/8] feat(foc): adding mainnet GA items under stress --- docker-compose.yaml | 4 + workload/FOC.md | 126 ++- workload/cmd/foc-sidecar/assertions.go | 68 ++ workload/cmd/foc-sidecar/main.go | 2 + .../cmd/stress-engine/foc_payment_security.go | 529 +++++++++++ .../cmd/stress-engine/foc_piece_security.go | 832 ++++++++++++++++++ workload/cmd/stress-engine/foc_resilience.go | 350 ++++++++ workload/cmd/stress-engine/main.go | 9 + workload/internal/foc/eth.go | 18 + workload/internal/foc/selectors.go | 1 + 10 files changed, 1910 insertions(+), 29 deletions(-) create mode 100644 workload/cmd/stress-engine/foc_payment_security.go create mode 100644 workload/cmd/stress-engine/foc_piece_security.go create mode 100644 workload/cmd/stress-engine/foc_resilience.go diff --git a/docker-compose.yaml b/docker-compose.yaml index f2a69cf7..f64cb658 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -204,6 +204,10 @@ services: # # ADVERSARIAL: economic security + griefing probes - STRESS_WEIGHT_PDP_GRIEFING=8 # fee extraction, insolvency, replay, burst attacks + # SECURITY SCENARIOS: piece lifecycle, payment rail, resilience + - STRESS_WEIGHT_FOC_PIECE_SECURITY=2 # piece lifecycle + attack probes (nonce replay, cross-DS, double-delete) + - STRESS_WEIGHT_FOC_PAYMENT_SECURITY=2 # rail settlement + audit L01/L04/L06/#288 + - STRESS_WEIGHT_FOC_RESILIENCE=1 # Curio HTTP stress + orphan rail billing # - CURIO_PDP_URL=http://curio:80 diff --git a/workload/FOC.md b/workload/FOC.md index eabea750..abb4ac3a 100644 --- a/workload/FOC.md +++ b/workload/FOC.md @@ -38,26 +38,30 @@ Contract logic executes deterministically inside FVM's WASM sandbox — unit tes ``` workload/ ├── cmd/ -│ ├── stress-engine/ # Main fuzz driver -│ │ ├── main.go # Init, deck building, main loop -│ │ ├── foc_vectors.go # FOC lifecycle + steady-state vectors -│ │ ├── actions.go # Non-FOC stress vectors (transfers, contracts, etc.) -│ │ └── contracts.go # Embedded EVM bytecodes -│ ├── foc-sidecar/ # Independent safety monitor -│ │ ├── main.go # Polling loop -│ │ ├── assertions.go # 5 safety assertions (assert.Always) -│ │ ├── events.go # Event log parsing (DataSetCreated, RailCreated, etc.) -│ │ └── state.go # Thread-safe state tracking -│ └── genesis-prep/ # Wallet generation (runs before stress-engine) +│ ├── stress-engine/ # Main fuzz driver +│ │ ├── main.go # Init, deck building, main loop +│ │ ├── foc_vectors.go # FOC lifecycle + steady-state vectors +│ │ ├── griefing_vectors.go # Payment griefing probes (fee extraction, insolvency, replay) +│ │ ├── foc_piece_security.go # Piece lifecycle security scenario (8 phases) +│ │ ├── foc_payment_security.go # Rail/payment security scenario (7 phases) +│ │ ├── foc_resilience.go # Curio resilience + orphan rail scenario (3 phases) +│ │ ├── actions.go # Non-FOC stress vectors (transfers, contracts, etc.) +│ │ └── contracts.go # Embedded EVM bytecodes +│ ├── foc-sidecar/ # Independent safety monitor +│ │ ├── main.go # Polling loop +│ │ ├── assertions.go # Safety assertions (assert.Always + assert.Sometimes) +│ │ ├── events.go # Event log parsing (DataSetCreated, RailCreated, etc.) +│ │ └── state.go # Thread-safe state tracking +│ └── genesis-prep/ # Wallet generation (runs before stress-engine) │ └── main.go └── internal/ - └── foc/ # Shared FOC library - ├── config.go # Parse /shared/environment.env + SP key - ├── eth.go # EVM tx submission (SendEthTx, SendEthTxConfirmed, BuildCalldata) - ├── eip712.go # EIP-712 typed data signing for FWSS - ├── curio.go # Curio PDP HTTP API client (upload, create dataset, add pieces) - ├── commp.go # PieceCIDv2 calculation (CommP) - └── selectors.go # ABI function selectors for all contracts + └── foc/ # Shared FOC library + ├── config.go # Parse /shared/environment.env + SP key + ├── eth.go # EVM tx submission + read helpers + ├── eip712.go # EIP-712 typed data signing for FWSS + ├── curio.go # Curio PDP HTTP API client + ├── commp.go # PieceCIDv2 calculation (CommP) + └── selectors.go # ABI function selectors for all contracts ``` ### Smart Contracts @@ -164,6 +168,65 @@ Resets the lifecycle to `Init` on success. **Destructive** — disabled by defau --- +## Security Scenarios + +Three scenario state machines test the full connected lifecycle with security edge cases. Each is a single deck entry that advances one phase per invocation. They use a dedicated secondary client wallet (set up by the griefing runtime) to avoid interfering with the primary FOC lifecycle. + +### Scenario 1: Piece Lifecycle Security (`foc_piece_security.go`, weight: 2) + +Tests the full piece add/delete/retrieve lifecycle with attack probes at each step. + +``` +Init → Added → Verified → DeleteScheduled → DeleteVerified → AttackPhase → Terminated → Cleanup +``` + +| Phase | What It Tests | Key Assertion | +|-------|--------------|---------------| +| **Init→Added** | Upload piece, add to dataset, verify `activePieceCount` increases | `Sometimes(countIncreased)` | +| **Added→Verified** | Download piece, recompute CID, verify integrity | `Sometimes(cidMatch)` | +| **Verified→DeleteScheduled** | Schedule deletion, immediately re-retrieve (**curio#1039** "prove deleted data" edge) | `Sometimes(retrievalClean)` | +| **DeleteScheduled→DeleteVerified** | Verify piece count decreased, proving still advances | `Sometimes(countDecreased)`, `Sometimes(provingAdvances)` | +| **DeleteVerified→AttackPhase** | Random attack (one per cycle): | | +| | — **Nonce replay**: reuse addPieces nonce | `Sometimes(replayRejected)` | +| | — **Cross-dataset injection**: sign for DS A, submit to DS B | `Sometimes(crossDSRejected)` | +| | — **Double deletion**: delete same pieceID twice | `Sometimes(doubleFails)` | +| | — **Nonexistent delete**: delete pieceID=999999 | `Sometimes(nonexistentFails)` | +| **AttackPhase→Terminated** | Call `terminateService`, then immediately try `addPieces` (**post-termination race**) | `Sometimes(postTermAddRejected)` | +| **Terminated→Cleanup** | Delete dataset, reset for next cycle | `Sometimes(cycleCompletes)` | + +### Scenario 2: Payment Rail Security (`foc_payment_security.go`, weight: 2) + +Tests the full payment rail lifecycle targeting audit findings. + +``` +Init → Settled → DoubleSettled → RailChecked → RateModified → Withdrawn → Refunded +``` + +| Phase | What It Tests | Audit Finding | Key Assertion | +|-------|--------------|---------------|---------------| +| **Init→Settled** | Settle rail, verify lockup ≤ before | **L01**: lockup after settlement | `Sometimes(lockupNoIncrease)` | +| **Settled→DoubleSettled** | Settle same rail+epoch again | Double-settle idempotency | `Sometimes(noExtraDeduction)` | +| **DoubleSettled→RailChecked** | Read all 3 rail IDs, verify cacheMiss+cdn rates=0 (no FILCDN/IPNI) | Rail config sanity | Logged for observability | +| **RailChecked→RateModified** | `modifyRailPayment` twice, verify latest persists | **L06**: rate queue clearing | `Sometimes(latestRatePersists)` | +| **RateModified→Withdrawn** | Withdraw all `available = funds - lockup` | **#288**: locked funds | `Sometimes(withdrawOK)` | +| **Withdrawn→Refunded** | Attacker deposits to victim's account + refund | **L04**: unauthorized deposit | `Always(!primaryInflated)` | + +### Scenario 3: Curio Resilience (`foc_resilience.go`, weight: 1) + +Tests Curio HTTP API resilience and orphan rail economics. + +``` +Init → OrphanCreated → OrphanChecked → (back to Init) +``` + +| Phase | What It Tests | Risks DB Item | Key Assertion | +|-------|--------------|---------------|---------------| +| **Init** | Send 7 malformed HTTP requests, verify Curio survives | Network-wide Curio crash (Sev2) | `Always(curioPingOK)` | +| **OrphanCreated** | Create empty dataset (no pieces), snapshot funds | Upload failures + orphan rails | — | +| **OrphanChecked** | Verify empty dataset doesn't accumulate charges, cleanup | Orphan rail billing | `Sometimes(noChargeForEmpty)` | + +--- + ## Assertions The Antithesis SDK provides three assertion types: @@ -193,20 +256,21 @@ All stress-engine assertions use `assert.Sometimes` because individual transacti ### Sidecar Assertions (`assertions.go`) -Sidecar assertions use `assert.Always` for safety invariants that must hold on every poll cycle. These run independently of the stress-engine against finalized chain state (30-epoch finality window). +Sidecar assertions run independently against finalized chain state (30-epoch finality window). | Assertion Message | Type | Function | What It Validates | |-------------------|------|----------|-------------------| -| `"Rail-to-dataset reverse mapping is consistent"` | Always | checkRailToDataset | `railToDataSet(pdpRailId)` returns the expected `dataSetId` for every tracked dataset. Detects rail/dataset mapping corruption. | -| `"FilecoinPay holds sufficient USDFC (solvency)"` | Always | checkFilecoinPaySolvency | `balanceOf(FilecoinPay)` >= sum of all tracked `accounts.funds + accounts.lockup`. Detects insolvency / phantom balance creation. | -| `"Provider ID matches registry for dataset"` | Always | checkProviderIDConsistency | `addressToProviderId(sp)` matches the `providerId` from the `DataSetCreated` event. Detects registry corruption or SP impersonation. | -| `"Active proofset is live on-chain"` | Always | checkProofSetLiveness | Every non-deleted dataset has `dataSetLive() == true`. Detects unexpected dataset termination or proof failure. | -| `"Deleted proofset is not live"` | Always | checkDeletedDataSetNotLive | Every deleted dataset has `dataSetLive() == false`. Detects zombie datasets that survive deletion. | - -| `"Proving period advances (challenge epoch changed)"` | Sometimes | checkProvingAdvancement | `getNextChallengeEpoch` changes over time for active datasets. Confirms proving pipeline is running. | -| `"Dataset proof submitted (proven epoch advanced)"` | Sometimes | checkProvingAdvancement | `getDataSetLastProvenEpoch` advances. Confirms Curio is submitting proofs. | -| `"Active piece count does not exceed leaf count"` | Always | checkPieceAccountingConsistency | `getActivePieceCount <= getDataSetLeafCount`. Detects piece accounting corruption. | -| `"Active dataset rail has non-zero payment rate"` | Always | checkRateConsistency | Datasets with pieces must have `paymentRate > 0` on their PDP rail. Detects rate miscalculation. | +| `"Rail-to-dataset reverse mapping is consistent"` | Always | checkRailToDataset | `railToDataSet(pdpRailId)` returns expected `dataSetId`. Detects mapping corruption. | +| `"FilecoinPay holds sufficient USDFC (solvency)"` | Always | checkFilecoinPaySolvency | `balanceOf(FilecoinPay)` >= sum of all `accounts.funds`. Detects insolvency. | +| `"Provider ID matches registry for dataset"` | Always | checkProviderIDConsistency | `addressToProviderId(sp)` matches `DataSetCreated` event. | +| `"Active proofset is live on-chain"` | Always | checkProofSetLiveness | Non-deleted datasets have `dataSetLive() == true`. | +| `"Deleted proofset is not live"` | Always | checkDeletedDataSetNotLive | Deleted datasets have `dataSetLive() == false`. | +| `"Proving period advances"` | Sometimes | checkProvingAdvancement | `getNextChallengeEpoch` changes over time. | +| `"Dataset proof submitted"` | Sometimes | checkProvingAdvancement | `getDataSetLastProvenEpoch` advances. | +| `"Active piece count ≤ leaf count"` | Always | checkPieceAccountingConsistency | Detects piece accounting corruption. | +| `"Active dataset rail has non-zero payment rate"` | Always | checkRateConsistency | Datasets with pieces must have `paymentRate > 0`. | +| `"Lockup never exceeds funds for any payer"` | Always | checkLockupNeverExceedsFunds | **Audit L01**: `lockup ≤ funds` for every tracked payer. Fundamental accounting invariant. | +| `"Deleted dataset rail has endEpoch set"` | Sometimes | checkDeletedDatasetRailTerminated | **#288**: Deleted dataset rails must be terminated. Detects zombie rails. | ### Event Tracking @@ -320,6 +384,10 @@ When the FOC profile is active, non-FOC stress vectors (EVM contracts, nonce cha | `STRESS_WEIGHT_FOC_WITHDRAW` | `2` | Steady-state | Withdraw USDFC from FilecoinPay | | `STRESS_WEIGHT_FOC_DELETE_PIECE` | `1` | Destructive | Schedule piece deletion from proofset | | `STRESS_WEIGHT_FOC_DELETE_DS` | `0` | Destructive | Delete entire dataset + reset lifecycle | +| `STRESS_WEIGHT_PDP_GRIEFING` | `8` | Adversarial | Payment griefing: fee extraction, insolvency, cross-payer replay, burst | +| `STRESS_WEIGHT_FOC_PIECE_SECURITY` | `2` | Security | Piece lifecycle: add/delete/retrieve + nonce replay, cross-DS, double-delete | +| `STRESS_WEIGHT_FOC_PAYMENT_SECURITY` | `2` | Security | Rail lifecycle: settlement lockup (L01), rate change (L06), unauthorized deposit (L04), withdrawal (#288) | +| `STRESS_WEIGHT_FOC_RESILIENCE` | `1` | Security | Curio HTTP resilience + orphan rail billing | --- diff --git a/workload/cmd/foc-sidecar/assertions.go b/workload/cmd/foc-sidecar/assertions.go index 28e259f0..b699f703 100644 --- a/workload/cmd/foc-sidecar/assertions.go +++ b/workload/cmd/foc-sidecar/assertions.go @@ -306,6 +306,74 @@ func checkPieceAccountingConsistency(ctx context.Context, node api.FullNode, cfg } } +// checkLockupNeverExceedsFunds verifies that for every tracked payer, +// lockup never exceeds funds. This is a fundamental accounting invariant +// of FilecoinPay — if lockup > funds, the contract is in an inconsistent state. +// (Audit L01 continuous monitoring) +func checkLockupNeverExceedsFunds(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.USDFCAddr == nil || cfg.FilPayAddr == nil { + return + } + + payers := state.GetTrackedPayers() + for _, payer := range payers { + funds := foc.ReadAccountFunds(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, payer) + lockup := foc.ReadAccountLockup(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, payer) + + if funds == nil || lockup == nil { + continue + } + + consistent := lockup.Cmp(funds) <= 0 + + assert.Always(consistent, "Lockup never exceeds funds for any payer", map[string]any{ + "payer": fmt.Sprintf("0x%x", payer), + "funds": funds.String(), + "lockup": lockup.String(), + }) + + if !consistent { + log.Printf("[lockup-invariant] VIOLATION: payer=%x lockup=%s > funds=%s", payer, lockup, funds) + } + } +} + +// checkDeletedDatasetRailTerminated verifies that for every deleted dataset, +// the associated PDP rail has an endEpoch set (rail is terminated). +// If a deleted dataset's rail has endEpoch=0, it's a zombie rail still +// consuming lockup — funds are stuck. (#288 continuous monitoring) +func checkDeletedDatasetRailTerminated(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.FilPayAddr == nil { + return + } + + datasets := state.GetDatasets() + for _, ds := range datasets { + if !ds.Deleted || ds.PDPRailID == 0 { + continue + } + + railData, err := foc.ReadRailFull(ctx, node, cfg.FilPayAddr, ds.PDPRailID) + if err != nil || len(railData) < 256 { + continue + } + + // endEpoch is at word index 7 (bytes 224-256) in the getRail return tuple + endEpoch := new(big.Int).SetBytes(railData[224:256]) + terminated := endEpoch.Sign() > 0 + + assert.Sometimes(terminated, "Deleted dataset rail has endEpoch set", map[string]any{ + "dataSetId": ds.DataSetID, + "pdpRailId": ds.PDPRailID, + "endEpoch": endEpoch.String(), + }) + + if !terminated { + log.Printf("[deleted-rail] dataset %d rail %d has endEpoch=0 after deletion — zombie rail", ds.DataSetID, ds.PDPRailID) + } + } +} + // checkRateConsistency verifies that active datasets with pieces have a // non-zero payment rate on their PDP rail. func checkRateConsistency(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { diff --git a/workload/cmd/foc-sidecar/main.go b/workload/cmd/foc-sidecar/main.go index 288f582d..8cdb9f7b 100644 --- a/workload/cmd/foc-sidecar/main.go +++ b/workload/cmd/foc-sidecar/main.go @@ -89,6 +89,8 @@ func main() { checkProvingAdvancement(ctx, node, cfg, state) checkPieceAccountingConsistency(ctx, node, cfg, state) checkRateConsistency(ctx, node, cfg, state) + checkLockupNeverExceedsFunds(ctx, node, cfg, state) + checkDeletedDatasetRailTerminated(ctx, node, cfg, state) lastPolledBlock = finalizedHeight pollCount++ diff --git a/workload/cmd/stress-engine/foc_payment_security.go b/workload/cmd/stress-engine/foc_payment_security.go new file mode 100644 index 00000000..84faa65d --- /dev/null +++ b/workload/cmd/stress-engine/foc_payment_security.go @@ -0,0 +1,529 @@ +package main + +import ( + "log" + "math/big" + "sync" + + "workload/internal/foc" + + "github.com/antithesishq/antithesis-sdk-go/assert" + "github.com/antithesishq/antithesis-sdk-go/random" +) + +// =========================================================================== +// Scenario 2: Payment Rail & Funds Security +// +// Tests the full payment rail lifecycle with audit finding checks at each step. +// Each deck invocation advances one phase. The scenario cycles continuously: +// +// Init → Settled → DoubleSettled → RailChecked → RateModified → +// Withdrawn → Refunded → (back to Init) +// +// Covers: +// - Audit L01: lockup accounting after settlement +// - Audit L04: unauthorized third-party deposit +// - Audit L06: rate change queue staleness +// - Issue #288: funds locked after lifecycle +// - Double settlement idempotency +// - 3-rail sanity check (no FILCDN/IPNI billing) +// +// Requires griefRuntime to be in griefReady state (secondary client set up). +// =========================================================================== + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +type paySecState int + +const ( + paySecInit paySecState = iota // snapshot funds/lockup, discover rails + paySecSettled // settle pdpRail, verify lockup (L01) + paySecDoubleSettled // settle same rail again, verify idempotent + paySecRailChecked // verify all 3 rails configuration + paySecRateModified // modify rate twice, verify latest persists (L06) + paySecWithdrawn // withdraw all available funds (#288) + paySecRefunded // re-deposit + test unauthorized deposit (L04) +) + +func (s paySecState) String() string { + switch s { + case paySecInit: + return "Init" + case paySecSettled: + return "Settled" + case paySecDoubleSettled: + return "DoubleSettled" + case paySecRailChecked: + return "RailChecked" + case paySecRateModified: + return "RateModified" + case paySecWithdrawn: + return "Withdrawn" + case paySecRefunded: + return "Refunded" + default: + return "Unknown" + } +} + +var ( + paySec paySecRuntime + paySecMu sync.Mutex +) + +type paySecRuntime struct { + State paySecState + + // Snapshot values + FundsBefore *big.Int + LockupBefore *big.Int + FundsAfter *big.Int + LockupAfter *big.Int + + // Rail discovery + RailID *big.Int + SettleEpoch *big.Int + + // Progress + Cycles int +} + +func paySecSnap() paySecRuntime { + paySecMu.Lock() + defer paySecMu.Unlock() + return paySec +} + +// --------------------------------------------------------------------------- +// DoFOCPaymentSecurityProbe — deck entry +// --------------------------------------------------------------------------- + +func DoFOCPaymentSecurityProbe() { + if focCfg == nil || focCfg.ClientKey == nil { + return + } + if _, ok := requireReady(); !ok { + return + } + + gs := griefSnap() + if gs.State != griefReady || gs.ClientKey == nil { + return + } + if focCfg.FilPayAddr == nil || focCfg.USDFCAddr == nil { + return + } + + paySecMu.Lock() + state := paySec.State + paySecMu.Unlock() + + switch state { + case paySecInit: + paySecDoInit() + case paySecSettled: + paySecDoDoubleSettle() + case paySecDoubleSettled: + paySecDoRailCheck() + case paySecRailChecked: + paySecDoRateModify() + case paySecRateModified: + paySecDoWithdraw() + case paySecWithdrawn: + paySecDoRefund() + case paySecRefunded: + // Reset + paySecMu.Lock() + paySec.Cycles++ + cycles := paySec.Cycles + paySec.State = paySecInit + paySec.RailID = nil + paySec.SettleEpoch = nil + paySec.FundsBefore = nil + paySec.LockupBefore = nil + paySec.FundsAfter = nil + paySec.LockupAfter = nil + paySecMu.Unlock() + log.Printf("[payment-security] cycle %d complete, resetting", cycles) + assert.Sometimes(true, "Payment security scenario cycle completes", map[string]any{ + "cycles": cycles, + }) + } +} + +// --------------------------------------------------------------------------- +// Phase 1: Snapshot + Settle (Audit L01) +// --------------------------------------------------------------------------- + +func paySecDoInit() { + gs := griefSnap() + node := focNode() + + // Read funds/lockup BEFORE + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + if funds == nil || funds.Sign() == 0 { + log.Printf("[payment-security] secondary client has no funds, skipping") + return + } + + // Discover rails for secondary client + railCalldata := foc.BuildCalldata(foc.SigGetRailsByPayer, + foc.EncodeAddress(gs.ClientEth), + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeBigInt(big.NewInt(0)), + foc.EncodeBigInt(big.NewInt(10)), + ) + result, err := foc.EthCallRaw(ctx, node, focCfg.FilPayAddr, railCalldata) + if err != nil || len(result) < 96 { + log.Printf("[payment-security] no rails found for secondary client") + return + } + + arrayLen := new(big.Int).SetBytes(result[32:64]) + if arrayLen.Sign() == 0 { + log.Printf("[payment-security] no rails found (empty array)") + return + } + railID := new(big.Int).SetBytes(result[64:96]) + + // Get current epoch for settlement + head, err := node.ChainHead(ctx) + if err != nil { + return + } + settleEpoch := big.NewInt(int64(head.Height())) + + // Settle the rail + settleCalldata := foc.BuildCalldata(foc.SigSettleRail, + foc.EncodeBigInt(railID), + foc.EncodeBigInt(settleEpoch), + ) + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, settleCalldata, "payment-security-settle") + if !ok { + log.Printf("[payment-security] settlement failed for railID=%s, will retry", railID) + return + } + + // Read funds/lockup AFTER + fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + lockupAfter := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + // Audit L01: lockup must not increase after settlement + if lockup != nil && lockupAfter != nil { + noIncrease := lockupAfter.Cmp(lockup) <= 0 + assert.Sometimes(noIncrease, "Lockup does not increase after settlement", map[string]any{ + "lockupBefore": lockup.String(), + "lockupAfter": lockupAfter.String(), + "railID": railID.String(), + "settleEpoch": settleEpoch.String(), + }) + if !noIncrease { + log.Printf("[payment-security] ANOMALY: lockup INCREASED after settlement: %s → %s", lockup, lockupAfter) + } + } + + log.Printf("[payment-security] settled railID=%s epoch=%s funds=%s→%s lockup=%s→%s", + railID, settleEpoch, funds, fundsAfter, lockup, lockupAfter) + + paySecMu.Lock() + paySec.FundsBefore = funds + paySec.LockupBefore = lockup + paySec.FundsAfter = fundsAfter + paySec.LockupAfter = lockupAfter + paySec.RailID = railID + paySec.SettleEpoch = settleEpoch + paySec.State = paySecSettled + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 2: Double Settlement — verify idempotent +// --------------------------------------------------------------------------- + +func paySecDoDoubleSettle() { + s := paySecSnap() + gs := griefSnap() + node := focNode() + + if s.RailID == nil || s.SettleEpoch == nil { + paySecMu.Lock() + paySec.State = paySecDoubleSettled + paySecMu.Unlock() + return + } + + // Read funds before second settle + fundsBefore2 := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + // Settle same rail at same epoch again + settleCalldata := foc.BuildCalldata(foc.SigSettleRail, + foc.EncodeBigInt(s.RailID), + foc.EncodeBigInt(s.SettleEpoch), + ) + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, settleCalldata, "payment-security-double-settle") + + // Read funds after second settle + fundsAfter2 := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + if fundsBefore2 != nil && fundsAfter2 != nil { + noExtraDeduction := fundsAfter2.Cmp(fundsBefore2) >= 0 + assert.Sometimes(noExtraDeduction, "Double settlement is idempotent", map[string]any{ + "fundsBefore2": fundsBefore2.String(), + "fundsAfter2": fundsAfter2.String(), + "railID": s.RailID.String(), + }) + if !noExtraDeduction { + delta := new(big.Int).Sub(fundsBefore2, fundsAfter2) + log.Printf("[payment-security] ANOMALY: double settle caused extra deduction of %s", delta) + } + } + + log.Printf("[payment-security] double settle complete: funds=%v→%v", fundsBefore2, fundsAfter2) + + paySecMu.Lock() + paySec.State = paySecDoubleSettled + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 3: Rail Sanity Check — verify 3-rail configuration +// --------------------------------------------------------------------------- + +func paySecDoRailCheck() { + gs := griefSnap() + node := focNode() + + if gs.LastOnChainDSID == 0 { + paySecMu.Lock() + paySec.State = paySecRailChecked + paySecMu.Unlock() + return + } + + // Read the pdpRailId payment rate (should be non-zero for active dataset) + s := paySecSnap() + if s.RailID != nil { + rate := foc.ReadRailPaymentRate(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) + if rate != nil { + log.Printf("[payment-security] pdpRail rate=%s", rate) + } + + // Read the full rail to check endEpoch (should be 0 for active rail) + railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) + if err == nil && len(railData) >= 256 { + endEpoch := new(big.Int).SetBytes(railData[224:256]) // word index 7 + log.Printf("[payment-security] pdpRail endEpoch=%s (0=active)", endEpoch) + } + } + + // Check active piece count — if zero and rate > 0, billing for empty storage + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + activeCount, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + + if activeCount != nil && s.RailID != nil { + rate := foc.ReadRailPaymentRate(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) + if rate != nil && activeCount.Sign() == 0 && rate.Sign() > 0 { + log.Printf("[payment-security] NOTE: zero pieces but non-zero rate=%s — may be expected during setup", rate) + } + } + + log.Printf("[payment-security] rail check complete: dsID=%d activePieces=%v", gs.LastOnChainDSID, activeCount) + + paySecMu.Lock() + paySec.State = paySecRailChecked + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 4: Rate Modification (Audit L06) +// --------------------------------------------------------------------------- + +func paySecDoRateModify() { + s := paySecSnap() + gs := griefSnap() + node := focNode() + + if s.RailID == nil { + paySecMu.Lock() + paySec.State = paySecRateModified + paySecMu.Unlock() + return + } + + // Generate two different rates + rate1 := new(big.Int).SetUint64(random.GetRandom()%1000 + 1) + rate2 := new(big.Int).SetUint64(random.GetRandom()%1000 + 1001) // guaranteed different + lockupPeriod := big.NewInt(3600) + + // First rate change + calldata1 := foc.BuildCalldata(foc.SigModifyRailPayment, + foc.EncodeBigInt(s.RailID), + foc.EncodeBigInt(rate1), + foc.EncodeBigInt(lockupPeriod), + ) + ok1 := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata1, "payment-security-rate1") + + if !ok1 { + log.Printf("[payment-security] rate change 1 failed (may not have permission), skipping L06 test") + paySecMu.Lock() + paySec.State = paySecRateModified + paySecMu.Unlock() + return + } + + // Second rate change (should overwrite first) + calldata2 := foc.BuildCalldata(foc.SigModifyRailPayment, + foc.EncodeBigInt(s.RailID), + foc.EncodeBigInt(rate2), + foc.EncodeBigInt(lockupPeriod), + ) + ok2 := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata2, "payment-security-rate2") + + if ok1 && ok2 { + // Read the rail to check which rate persisted + railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) + if err == nil && len(railData) >= 192 { + currentRate := new(big.Int).SetBytes(railData[128:160]) // word index 4 = paymentRate + log.Printf("[payment-security] after two rate changes: currentRate=%s rate1=%s rate2=%s", currentRate, rate1, rate2) + + // The latest rate (rate2) should be the one that persists + latestPersists := currentRate.Cmp(rate1) != 0 + assert.Sometimes(latestPersists, "Latest rate change replaces stale pending change", map[string]any{ + "rate1": rate1.String(), + "rate2": rate2.String(), + "currentRate": currentRate.String(), + "railID": s.RailID.String(), + }) + } + } + + paySecMu.Lock() + paySec.State = paySecRateModified + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 5: Withdraw All Available Funds (#288) +// --------------------------------------------------------------------------- + +func paySecDoWithdraw() { + gs := griefSnap() + node := focNode() + + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + if funds == nil || lockup == nil { + paySecMu.Lock() + paySec.State = paySecWithdrawn + paySecMu.Unlock() + return + } + + available := new(big.Int).Sub(funds, lockup) + if available.Sign() <= 0 { + log.Printf("[payment-security] no available funds to withdraw (funds=%s lockup=%s)", funds, lockup) + paySecMu.Lock() + paySec.State = paySecWithdrawn + paySecMu.Unlock() + return + } + + calldata := foc.BuildCalldata(foc.SigWithdraw, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeBigInt(available), + ) + + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "payment-security-withdraw") + + assert.Sometimes(ok, "Full withdrawal after settlement succeeds", map[string]any{ + "funds": funds.String(), + "lockup": lockup.String(), + "available": available.String(), + "ok": ok, + }) + + if !ok { + log.Printf("[payment-security] ANOMALY: withdrawal of available=%s FAILED (funds=%s lockup=%s) — possible locked funds", available, funds, lockup) + } else { + log.Printf("[payment-security] withdrawn %s (funds=%s lockup=%s)", available, funds, lockup) + } + + paySecMu.Lock() + paySec.State = paySecWithdrawn + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 6: Refund + Unauthorized Deposit Test (Audit L04) +// --------------------------------------------------------------------------- + +func paySecDoRefund() { + gs := griefSnap() + node := focNode() + + // ---- Audit L04: unauthorized third-party deposit test ---- + // Secondary client (attacker) tries to deposit to PRIMARY client's account + primaryFundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + + smallAmount := big.NewInt(1000000000000000) // 0.001 USDFC + depositCalldata := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(focCfg.ClientEthAddr), // target: PRIMARY client + foc.EncodeBigInt(smallAmount), + ) + + depositOK := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, depositCalldata, "payment-security-unauth-deposit") + + primaryFundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + + if primaryFundsBefore != nil && primaryFundsAfter != nil { + inflated := primaryFundsAfter.Cmp(primaryFundsBefore) > 0 + // TRUE SAFETY INVARIANT: third party cannot inflate someone's funds + assert.Always(!inflated || !depositOK, "Third-party deposit cannot inflate target account", map[string]any{ + "primaryBefore": primaryFundsBefore.String(), + "primaryAfter": primaryFundsAfter.String(), + "depositOK": depositOK, + "attacker": "secondary_client", + }) + if inflated && depositOK { + log.Printf("[payment-security] CRITICAL: unauthorized deposit inflated primary funds: %s → %s", primaryFundsBefore, primaryFundsAfter) + } + } + + // ---- Refund secondary client for next cycle ---- + refundAmount := big.NewInt(griefUSDFCDeposit) + refundCalldata := foc.BuildCalldata(foc.SigTransfer, + foc.EncodeAddress(gs.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, refundCalldata, "payment-security-refund") + + // Re-deposit into FilecoinPay + redeposit := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(gs.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, redeposit, "payment-security-redeposit") + + log.Printf("[payment-security] refund complete, advancing to next cycle") + + paySecMu.Lock() + paySec.State = paySecRefunded + paySecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Progress +// --------------------------------------------------------------------------- + +func logPaySecProgress() { + s := paySecSnap() + log.Printf("[payment-security] state=%s cycles=%d railID=%v", + s.State, s.Cycles, s.RailID) +} diff --git a/workload/cmd/stress-engine/foc_piece_security.go b/workload/cmd/stress-engine/foc_piece_security.go new file mode 100644 index 00000000..7b1c59b1 --- /dev/null +++ b/workload/cmd/stress-engine/foc_piece_security.go @@ -0,0 +1,832 @@ +package main + +import ( + "encoding/hex" + "log" + "math/big" + "sync" + + "workload/internal/foc" + + "github.com/antithesishq/antithesis-sdk-go/assert" + "github.com/antithesishq/antithesis-sdk-go/random" + "github.com/ipfs/go-cid" +) + +// =========================================================================== +// Scenario 1: Piece Lifecycle Security +// +// Tests the full piece add/delete/retrieve lifecycle with security edge cases. +// Each deck invocation advances one phase. The scenario cycles continuously: +// +// Init → Added → Verified → DeleteScheduled → DeleteVerified → +// AttackPhase → Terminated → Cleanup → (back to Init) +// +// Covers: +// - Piece add/delete accounting correctness +// - Retrieval integrity before and after deletion (curio#1039) +// - Proving continuity after deletion +// - Nonce replay attacks on addPieces (EIP-712) +// - Cross-dataset piece injection +// - Double piece deletion +// - Post-termination piece addition race +// +// Requires griefRuntime to be in griefReady state (secondary client set up). +// =========================================================================== + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +type pieceSecState int + +const ( + pieceSecInit pieceSecState = iota // upload piece to Curio + pieceSecAdded // piece added on-chain, snapshot counts + pieceSecVerified // retrieval integrity verified + pieceSecDeleteScheduled // deletion scheduled, re-retrieve check + pieceSecDeleteVerified // piece count decreased, proving OK + pieceSecAttackPhase // random attack probe + pieceSecTerminated // terminate service, try post-term add + pieceSecCleanup // delete dataset, re-create, reset +) + +func (s pieceSecState) String() string { + switch s { + case pieceSecInit: + return "Init" + case pieceSecAdded: + return "Added" + case pieceSecVerified: + return "Verified" + case pieceSecDeleteScheduled: + return "DeleteScheduled" + case pieceSecDeleteVerified: + return "DeleteVerified" + case pieceSecAttackPhase: + return "AttackPhase" + case pieceSecTerminated: + return "Terminated" + case pieceSecCleanup: + return "Cleanup" + default: + return "Unknown" + } +} + +var ( + pieceSec pieceSecRuntime + pieceSecMu sync.Mutex +) + +type pieceSecRuntime struct { + State pieceSecState + + // Piece under test + PieceCID string + PieceID int + Nonce *big.Int // nonce used for the addPieces EIP-712 signature + + // Snapshots for before/after comparison + CountBefore *big.Int + CountAfter *big.Int + ProvenBefore uint64 + TermDataSetID int // dataset ID being terminated (for cleanup) + TermClientDSID *big.Int // clientDataSetId for the terminated dataset + + // Progress + Cycles int + AttacksDone int +} + +func pieceSecSnap() pieceSecRuntime { + pieceSecMu.Lock() + defer pieceSecMu.Unlock() + return pieceSec +} + +// --------------------------------------------------------------------------- +// DoFOCPieceSecurityProbe — deck entry +// --------------------------------------------------------------------------- + +func DoFOCPieceSecurityProbe() { + if focCfg == nil || focCfg.ClientKey == nil { + return + } + if _, ok := requireReady(); !ok { + return + } + + // Wait for griefing secondary client to be ready + gs := griefSnap() + if gs.State != griefReady || gs.ClientKey == nil { + return + } + if gs.LastOnChainDSID == 0 { + return // need a griefing dataset to operate on + } + + pieceSecMu.Lock() + state := pieceSec.State + pieceSecMu.Unlock() + + switch state { + case pieceSecInit: + pieceSecDoInit() + case pieceSecAdded: + pieceSecDoVerify() + case pieceSecVerified: + pieceSecDoScheduleDelete() + case pieceSecDeleteScheduled: + pieceSecDoVerifyDelete() + case pieceSecDeleteVerified: + pieceSecDoAttack() + case pieceSecAttackPhase: + pieceSecDoTerminate() + case pieceSecTerminated: + pieceSecDoCleanup() + case pieceSecCleanup: + // Cleanup resets to Init internally + pieceSecDoCleanup() + } +} + +// --------------------------------------------------------------------------- +// Phase 1: Upload + Add Piece +// --------------------------------------------------------------------------- + +func pieceSecDoInit() { + if !foc.PingCurio(ctx) { + return + } + gs := griefSnap() + node := focNode() + + // Upload a small random piece + size := 128 + rngIntn(384) + data := make([]byte, size) + for i := range data { + data[i] = byte(random.GetRandom() & 0xFF) + } + + pieceCID, err := foc.CalculatePieceCID(data) + if err != nil { + log.Printf("[piece-security] CalculatePieceCID failed: %v", err) + return + } + + if err := foc.UploadPiece(ctx, data, pieceCID); err != nil { + log.Printf("[piece-security] UploadPiece failed: %v", err) + return + } + + if err := foc.WaitForPiece(ctx, pieceCID); err != nil { + log.Printf("[piece-security] WaitForPiece failed: %v", err) + return + } + + // Snapshot active piece count BEFORE + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + countBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + + // Add piece to griefing dataset + nonce := new(big.Int).SetUint64(random.GetRandom()) + + parsedCID, err := cid.Decode(pieceCID) + if err != nil { + log.Printf("[piece-security] CID decode failed: %v", err) + return + } + cidBytes := parsedCID.Bytes() + + sig, err := foc.SignEIP712AddPieces( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, nonce, + [][]byte{cidBytes}, nil, nil, + ) + if err != nil { + log.Printf("[piece-security] EIP-712 signing failed: %v", err) + return + } + + extraData := encodeAddPiecesExtraData(nonce, 1, sig) + txHash, err := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{pieceCID}, hex.EncodeToString(extraData)) + if err != nil { + log.Printf("[piece-security] AddPiecesHTTP failed: %v", err) + return + } + + pieceIDs, err := foc.WaitForPieceAddition(ctx, gs.LastOnChainDSID, txHash) + if err != nil { + log.Printf("[piece-security] WaitForPieceAddition failed: %v", err) + return + } + + pieceID := 0 + if len(pieceIDs) > 0 { + pieceID = pieceIDs[0] + } + + // Snapshot count AFTER + countAfter, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + + if countBefore != nil && countAfter != nil { + increased := countAfter.Cmp(countBefore) > 0 + assert.Sometimes(increased, "Active piece count increases after addition", map[string]any{ + "countBefore": countBefore.String(), + "countAfter": countAfter.String(), + "pieceCID": pieceCID, + }) + } + + log.Printf("[piece-security] piece added: cid=%s pieceID=%d countBefore=%v countAfter=%v", + pieceCID, pieceID, countBefore, countAfter) + + pieceSecMu.Lock() + pieceSec.PieceCID = pieceCID + pieceSec.PieceID = pieceID + pieceSec.Nonce = nonce + pieceSec.CountBefore = countBefore + pieceSec.CountAfter = countAfter + pieceSec.State = pieceSecAdded + pieceSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 2: Retrieve + Verify CID Integrity +// --------------------------------------------------------------------------- + +func pieceSecDoVerify() { + s := pieceSecSnap() + + data, err := foc.DownloadPiece(ctx, s.PieceCID) + if err != nil { + log.Printf("[piece-security] download failed for %s: %v", s.PieceCID, err) + return // will retry next invocation + } + + computedCID, err := foc.CalculatePieceCID(data) + if err != nil { + log.Printf("[piece-security] CalculatePieceCID failed: %v", err) + return + } + + match := computedCID == s.PieceCID + assert.Sometimes(match, "Retrieved piece matches uploaded CID", map[string]any{ + "pieceCID": s.PieceCID, + "computedCID": computedCID, + "dataLen": len(data), + }) + + if !match { + log.Printf("[piece-security] INTEGRITY MISMATCH: expected=%s computed=%s", s.PieceCID, computedCID) + } else { + log.Printf("[piece-security] integrity verified: cid=%s", s.PieceCID) + } + + pieceSecMu.Lock() + pieceSec.State = pieceSecVerified + pieceSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 3: Schedule Deletion + Re-Retrieve (curio#1039) +// --------------------------------------------------------------------------- + +func pieceSecDoScheduleDelete() { + s := pieceSecSnap() + gs := griefSnap() + node := focNode() + + if s.PieceID == 0 { + // Can't delete without a valid piece ID — skip to attack phase + log.Printf("[piece-security] skipping delete (pieceID=0), advancing to attack phase") + pieceSecMu.Lock() + pieceSec.State = pieceSecDeleteVerified + pieceSecMu.Unlock() + return + } + + if focCfg.SPKey == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + + // Snapshot proven epoch before deletion + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + provenBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetLastProvenEpoch, dsIDBytes)) + countBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + + // Schedule piece deletion + pieceIDBig := big.NewInt(int64(s.PieceID)) + sig, err := foc.SignEIP712SchedulePieceRemovals( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, []*big.Int{pieceIDBig}, + ) + if err != nil { + log.Printf("[piece-security] EIP-712 deletion signing failed: %v", err) + return + } + + extraData := encodeBytes(sig) + calldata := foc.BuildCalldata(foc.SigSchedulePieceDeletions, + foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))), + foc.EncodeBigInt(big.NewInt(96)), + foc.EncodeBigInt(big.NewInt(160)), + foc.EncodeBigInt(big.NewInt(1)), + foc.EncodeBigInt(pieceIDBig), + extraData, + ) + + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-delete") + if !ok { + log.Printf("[piece-security] schedulePieceDeletions failed, will retry") + return + } + + log.Printf("[piece-security] deletion scheduled for pieceID=%d", s.PieceID) + + // curio#1039: immediately try to retrieve the piece after deletion scheduled + // The "no byte-level deletion" behavior means data should still be on disk + retrieveData, retrieveErr := foc.DownloadPiece(ctx, s.PieceCID) + if retrieveErr != nil { + log.Printf("[piece-security] post-delete retrieval: %v (expected if deletion processed)", retrieveErr) + } else { + // Retrieval succeeded — verify it's not corrupt + computedCID, cidErr := foc.CalculatePieceCID(retrieveData) + if cidErr != nil { + log.Printf("[piece-security] CRITICAL: post-delete retrieval returned data but CID computation failed: %v", cidErr) + } else { + clean := computedCID == s.PieceCID + assert.Sometimes(clean, "Piece still retrievable after deletion scheduled", map[string]any{ + "pieceCID": s.PieceCID, + "computedCID": computedCID, + "dataLen": len(retrieveData), + }) + if !clean { + log.Printf("[piece-security] CRITICAL: post-delete data CORRUPTED: expected=%s got=%s", s.PieceCID, computedCID) + } else { + log.Printf("[piece-security] post-delete retrieval OK (no byte-level deletion confirmed)") + } + } + } + + var provenBeforeU64 uint64 + if provenBefore != nil { + provenBeforeU64 = provenBefore.Uint64() + } + + pieceSecMu.Lock() + pieceSec.ProvenBefore = provenBeforeU64 + pieceSec.CountBefore = countBefore + pieceSec.State = pieceSecDeleteScheduled + pieceSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 4: Verify Delete — piece count decreased, proving continues +// --------------------------------------------------------------------------- + +func pieceSecDoVerifyDelete() { + s := pieceSecSnap() + gs := griefSnap() + node := focNode() + + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + + countAfter, err := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + if err != nil { + log.Printf("[piece-security] getActivePieceCount failed: %v", err) + return + } + + if s.CountBefore != nil && countAfter != nil { + decreased := countAfter.Cmp(s.CountBefore) < 0 + assert.Sometimes(decreased, "Active piece count decreases after deletion", map[string]any{ + "countBefore": s.CountBefore.String(), + "countAfter": countAfter.String(), + "pieceID": s.PieceID, + }) + log.Printf("[piece-security] delete verified: countBefore=%s countAfter=%s", s.CountBefore, countAfter) + } + + // Check proving still advances + provenAfter, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetLastProvenEpoch, dsIDBytes)) + if provenAfter != nil { + advanced := provenAfter.Uint64() >= s.ProvenBefore + assert.Sometimes(advanced, "Proving continues after piece deletion", map[string]any{ + "provenBefore": s.ProvenBefore, + "provenAfter": provenAfter.Uint64(), + }) + } + + pieceSecMu.Lock() + pieceSec.CountAfter = countAfter + pieceSec.State = pieceSecDeleteVerified + pieceSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 5: Attack Phase — randomly pick one attack per cycle +// --------------------------------------------------------------------------- + +func pieceSecDoAttack() { + gs := griefSnap() + s := pieceSecSnap() + + type attack struct { + name string + fn func(griefRuntime, pieceSecRuntime) + } + attacks := []attack{ + {"NonceReplay", attackNonceReplay}, + {"CrossDatasetInject", attackCrossDataset}, + {"DoubleDeletion", attackDoubleDeletion}, + {"NonexistentDelete", attackNonexistentDelete}, + } + + pick := attacks[rngIntn(len(attacks))] + log.Printf("[piece-security] attack: %s", pick.name) + pick.fn(gs, s) + + pieceSecMu.Lock() + pieceSec.AttacksDone++ + pieceSec.State = pieceSecAttackPhase + pieceSecMu.Unlock() +} + +// attackNonceReplay reuses the nonce from the previous addPieces call. +func attackNonceReplay(gs griefRuntime, s pieceSecRuntime) { + if s.Nonce == nil || !foc.PingCurio(ctx) { + return + } + + // Upload a new piece + data := make([]byte, 128) + for i := range data { + data[i] = byte(random.GetRandom() & 0xFF) + } + newCID, err := foc.CalculatePieceCID(data) + if err != nil { + return + } + if err := foc.UploadPiece(ctx, data, newCID); err != nil { + return + } + _ = foc.WaitForPiece(ctx, newCID) + + parsedCID, err := cid.Decode(newCID) + if err != nil { + return + } + + // Sign with the SAME nonce as the previous add + sig, err := foc.SignEIP712AddPieces( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, s.Nonce, // replayed nonce + [][]byte{parsedCID.Bytes()}, nil, nil, + ) + if err != nil { + return + } + + extraData := encodeAddPiecesExtraData(s.Nonce, 1, sig) + _, httpErr := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{newCID}, hex.EncodeToString(extraData)) + + if httpErr != nil { + log.Printf("[piece-security] nonce replay rejected at HTTP: %v", httpErr) + assert.Sometimes(true, "AddPieces nonce replay rejected", map[string]any{ + "replayedNonce": s.Nonce.String(), + }) + } else { + // HTTP accepted — check if it actually lands on-chain + log.Printf("[piece-security] CRITICAL: nonce replay accepted by Curio HTTP — checking on-chain") + // Even if HTTP accepted, the contract should reject it + assert.Sometimes(false, "AddPieces nonce replay rejected", map[string]any{ + "replayedNonce": s.Nonce.String(), + "note": "HTTP accepted replayed nonce — contract may still reject", + }) + } +} + +// attackCrossDataset signs addPieces for the griefing dataset but submits to the primary FOC dataset. +func attackCrossDataset(gs griefRuntime, _ pieceSecRuntime) { + if !foc.PingCurio(ctx) { + return + } + focS := snap() + if focS.OnChainDataSetID == 0 || gs.LastOnChainDSID == 0 { + return + } + if focS.OnChainDataSetID == gs.LastOnChainDSID { + return // same dataset, not a meaningful test + } + + data := make([]byte, 128) + for i := range data { + data[i] = byte(random.GetRandom() & 0xFF) + } + newCID, err := foc.CalculatePieceCID(data) + if err != nil { + return + } + if err := foc.UploadPiece(ctx, data, newCID); err != nil { + return + } + _ = foc.WaitForPiece(ctx, newCID) + + parsedCID, err := cid.Decode(newCID) + if err != nil { + return + } + nonce := new(big.Int).SetUint64(random.GetRandom()) + + // Sign for GRIEFING dataset + sig, err := foc.SignEIP712AddPieces( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, nonce, // griefing clientDataSetId + [][]byte{parsedCID.Bytes()}, nil, nil, + ) + if err != nil { + return + } + + extraData := encodeAddPiecesExtraData(nonce, 1, sig) + + // Submit to PRIMARY FOC dataset — signature mismatch + _, httpErr := foc.AddPiecesHTTP(ctx, focS.OnChainDataSetID, []string{newCID}, hex.EncodeToString(extraData)) + + if httpErr != nil { + log.Printf("[piece-security] cross-dataset injection rejected: %v", httpErr) + assert.Sometimes(true, "Cross-dataset piece injection rejected", map[string]any{ + "signedFor": gs.LastOnChainDSID, + "submittedTo": focS.OnChainDataSetID, + }) + } else { + log.Printf("[piece-security] CRITICAL: cross-dataset injection accepted by HTTP") + assert.Sometimes(false, "Cross-dataset piece injection rejected", map[string]any{ + "signedFor": gs.LastOnChainDSID, + "submittedTo": focS.OnChainDataSetID, + }) + } +} + +// attackDoubleDeletion tries to delete the same pieceID that was already deleted in phase 3. +func attackDoubleDeletion(gs griefRuntime, s pieceSecRuntime) { + if s.PieceID == 0 || focCfg.SPKey == nil { + return + } + node := focNode() + + pieceIDBig := big.NewInt(int64(s.PieceID)) + sig, err := foc.SignEIP712SchedulePieceRemovals( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, []*big.Int{pieceIDBig}, + ) + if err != nil { + return + } + + extraData := encodeBytes(sig) + calldata := foc.BuildCalldata(foc.SigSchedulePieceDeletions, + foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))), + foc.EncodeBigInt(big.NewInt(96)), + foc.EncodeBigInt(big.NewInt(160)), + foc.EncodeBigInt(big.NewInt(1)), + foc.EncodeBigInt(pieceIDBig), + extraData, + ) + + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-double-del") + + if !ok { + log.Printf("[piece-security] double deletion correctly rejected for pieceID=%d", s.PieceID) + assert.Sometimes(true, "Double piece deletion rejected", map[string]any{ + "pieceID": s.PieceID, + }) + } else { + log.Printf("[piece-security] CRITICAL: double deletion SUCCEEDED for pieceID=%d", s.PieceID) + assert.Sometimes(false, "Double piece deletion rejected", map[string]any{ + "pieceID": s.PieceID, + "note": "same piece deleted twice — accounting bug", + }) + } +} + +// attackNonexistentDelete tries to delete a piece ID that doesn't exist. +func attackNonexistentDelete(gs griefRuntime, _ pieceSecRuntime) { + if focCfg.SPKey == nil { + return + } + node := focNode() + + fakePieceID := big.NewInt(int64(999999 + rngIntn(1000000))) + sig, err := foc.SignEIP712SchedulePieceRemovals( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, []*big.Int{fakePieceID}, + ) + if err != nil { + return + } + + extraData := encodeBytes(sig) + calldata := foc.BuildCalldata(foc.SigSchedulePieceDeletions, + foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))), + foc.EncodeBigInt(big.NewInt(96)), + foc.EncodeBigInt(big.NewInt(160)), + foc.EncodeBigInt(big.NewInt(1)), + foc.EncodeBigInt(fakePieceID), + extraData, + ) + + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-fake-del") + + if !ok { + log.Printf("[piece-security] nonexistent piece deletion correctly rejected (fakeID=%s)", fakePieceID) + assert.Sometimes(true, "Nonexistent piece deletion rejected", map[string]any{ + "fakePieceID": fakePieceID.String(), + }) + } else { + log.Printf("[piece-security] CRITICAL: nonexistent piece deletion SUCCEEDED (fakeID=%s)", fakePieceID) + assert.Sometimes(false, "Nonexistent piece deletion rejected", map[string]any{ + "fakePieceID": fakePieceID.String(), + }) + } +} + +// --------------------------------------------------------------------------- +// Phase 6: Post-Termination Piece Addition Race +// --------------------------------------------------------------------------- + +func pieceSecDoTerminate() { + gs := griefSnap() + node := focNode() + + if focCfg.SPKey == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + if !foc.PingCurio(ctx) { + return + } + + // Terminate the griefing dataset + calldata := foc.BuildCalldata(foc.SigTerminateService, + foc.EncodeBigInt(gs.LastClientDSID), + ) + + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.FWSSAddr, calldata, "piece-security-terminate") + if !ok { + log.Printf("[piece-security] terminateService failed, will retry") + return + } + + log.Printf("[piece-security] service terminated for dataset=%d", gs.LastOnChainDSID) + + // THE KEY TEST: immediately try to add a piece after termination + data := make([]byte, 128) + for i := range data { + data[i] = byte(random.GetRandom() & 0xFF) + } + newCID, err := foc.CalculatePieceCID(data) + if err != nil { + log.Printf("[piece-security] post-term CID calc failed: %v", err) + pieceSecMu.Lock() + pieceSec.TermDataSetID = gs.LastOnChainDSID + pieceSec.TermClientDSID = gs.LastClientDSID + pieceSec.State = pieceSecTerminated + pieceSecMu.Unlock() + return + } + + _ = foc.UploadPiece(ctx, data, newCID) + _ = foc.WaitForPiece(ctx, newCID) + + parsedCID, err := cid.Decode(newCID) + if err != nil { + pieceSecMu.Lock() + pieceSec.TermDataSetID = gs.LastOnChainDSID + pieceSec.TermClientDSID = gs.LastClientDSID + pieceSec.State = pieceSecTerminated + pieceSecMu.Unlock() + return + } + + nonce := new(big.Int).SetUint64(random.GetRandom()) + sig, err := foc.SignEIP712AddPieces( + gs.ClientKey, focCfg.FWSSAddr, + gs.LastClientDSID, nonce, + [][]byte{parsedCID.Bytes()}, nil, nil, + ) + if err != nil { + pieceSecMu.Lock() + pieceSec.TermDataSetID = gs.LastOnChainDSID + pieceSec.TermClientDSID = gs.LastClientDSID + pieceSec.State = pieceSecTerminated + pieceSecMu.Unlock() + return + } + + extraData := encodeAddPiecesExtraData(nonce, 1, sig) + _, httpErr := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{newCID}, hex.EncodeToString(extraData)) + + if httpErr != nil { + log.Printf("[piece-security] post-termination add rejected: %v", httpErr) + assert.Sometimes(true, "Piece addition blocked after termination", map[string]any{ + "dataSetID": gs.LastOnChainDSID, + }) + } else { + log.Printf("[piece-security] CRITICAL: post-termination add ACCEPTED for dataset=%d", gs.LastOnChainDSID) + assert.Sometimes(false, "Piece addition blocked after termination", map[string]any{ + "dataSetID": gs.LastOnChainDSID, + "note": "pieces added to dying dataset — orphan risk", + }) + } + + pieceSecMu.Lock() + pieceSec.TermDataSetID = gs.LastOnChainDSID + pieceSec.TermClientDSID = gs.LastClientDSID + pieceSec.State = pieceSecTerminated + pieceSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 7: Cleanup — delete dataset, re-create, reset +// --------------------------------------------------------------------------- + +func pieceSecDoCleanup() { + s := pieceSecSnap() + gs := griefSnap() + node := focNode() + + if focCfg.SPKey == nil { + return + } + + // Delete the terminated dataset + if s.TermDataSetID > 0 && s.TermClientDSID != nil { + sig, err := foc.SignEIP712DeleteDataSet(gs.ClientKey, focCfg.FWSSAddr, s.TermClientDSID) + if err != nil { + log.Printf("[piece-security] deleteDataSet EIP-712 signing failed: %v", err) + // Don't block — reset anyway + } else { + extraData := encodeBytes(sig) + calldata := foc.BuildCalldata(foc.SigDeleteDataSet, + foc.EncodeBigInt(big.NewInt(int64(s.TermDataSetID))), + foc.EncodeBigInt(big.NewInt(64)), + extraData, + ) + sent := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-cleanup") + if sent { + log.Printf("[piece-security] dataset %d deleted", s.TermDataSetID) + } else { + log.Printf("[piece-security] dataset %d delete failed (endEpoch may not have passed yet), will retry", s.TermDataSetID) + return // retry next invocation + } + } + } + + // Re-create a new dataset for the griefing runtime via probeEmptyDatasetFee flow + // This is handled by the griefing probe on its next invocation once it detects + // the dataset was deleted. We just need to reset griefRT state. + griefMu.Lock() + griefRT.LastOnChainDSID = 0 + griefRT.LastClientDSID = nil + griefRT.DSCreated = 0 + griefMu.Unlock() + + pieceSecMu.Lock() + pieceSec.Cycles++ + cycles := pieceSec.Cycles + attacks := pieceSec.AttacksDone + pieceSec.State = pieceSecInit + pieceSec.PieceCID = "" + pieceSec.PieceID = 0 + pieceSec.Nonce = nil + pieceSec.CountBefore = nil + pieceSec.CountAfter = nil + pieceSec.ProvenBefore = 0 + pieceSec.TermDataSetID = 0 + pieceSec.TermClientDSID = nil + pieceSecMu.Unlock() + + log.Printf("[piece-security] cycle %d complete (attacks=%d), resetting to Init", cycles, attacks) + assert.Sometimes(true, "Piece security scenario cycle completes", map[string]any{ + "cycles": cycles, + "attacks": attacks, + }) +} + +// --------------------------------------------------------------------------- +// Progress +// --------------------------------------------------------------------------- + +func logPieceSecProgress() { + s := pieceSecSnap() + log.Printf("[piece-security] state=%s cycles=%d attacks=%d pieceCID=%s pieceID=%d", + s.State, s.Cycles, s.AttacksDone, s.PieceCID, s.PieceID) +} diff --git a/workload/cmd/stress-engine/foc_resilience.go b/workload/cmd/stress-engine/foc_resilience.go new file mode 100644 index 00000000..25fd51fd --- /dev/null +++ b/workload/cmd/stress-engine/foc_resilience.go @@ -0,0 +1,350 @@ +package main + +import ( + "bytes" + "encoding/hex" + "io" + "log" + "math/big" + "net/http" + "sync" + "time" + + "workload/internal/foc" + + "github.com/antithesishq/antithesis-sdk-go/assert" + "github.com/antithesishq/antithesis-sdk-go/random" +) + +// =========================================================================== +// Scenario 3: Curio Resilience & Orphan Rails +// +// Tests Curio's HTTP API resilience under malformed input and exercises the +// orphan rail scenario (dataset created but never populated with data). +// +// Init → OrphanCreated → OrphanChecked → (back to Init) +// +// Phase Init also runs the HTTP stress barrage on every cycle. +// +// Covers: +// - Risks DB "Network-wide Curio crash" (Sev2, NO mitigation) +// - Risks DB "Upload failures + orphan rails" (HIGH) +// - Curio HTTP API does not crash on malformed requests +// - Empty datasets do not accumulate storage charges +// =========================================================================== + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +type resState int + +const ( + resInit resState = iota // HTTP stress barrage + resOrphanCreated // empty dataset created, waiting to check billing + resOrphanChecked // billing verified, cleanup +) + +func (s resState) String() string { + switch s { + case resInit: + return "Init" + case resOrphanCreated: + return "OrphanCreated" + case resOrphanChecked: + return "OrphanChecked" + default: + return "Unknown" + } +} + +var ( + resSec resRuntime + resSecMu sync.Mutex +) + +type resRuntime struct { + State resState + + // Orphan dataset tracking + OrphanDSID int + OrphanFundsBefore *big.Int + + // Progress + Cycles int + HTTPBarrages int +} + +func resSnap() resRuntime { + resSecMu.Lock() + defer resSecMu.Unlock() + return resSec +} + +// --------------------------------------------------------------------------- +// DoFOCResilienceProbe — deck entry +// --------------------------------------------------------------------------- + +func DoFOCResilienceProbe() { + if focCfg == nil || focCfg.ClientKey == nil { + return + } + if _, ok := requireReady(); !ok { + return + } + + gs := griefSnap() + if gs.State != griefReady || gs.ClientKey == nil { + return + } + + if !foc.PingCurio(ctx) { + return + } + + resSecMu.Lock() + state := resSec.State + resSecMu.Unlock() + + switch state { + case resInit: + resDoHTTPStress() + case resOrphanCreated: + resDoOrphanCheck() + case resOrphanChecked: + resDoOrphanCleanup() + } +} + +// --------------------------------------------------------------------------- +// Phase 1: HTTP Stress Barrage + Create Orphan Dataset +// --------------------------------------------------------------------------- + +func resDoHTTPStress() { + gs := griefSnap() + node := focNode() + base := foc.CurioBaseURL() + client := &http.Client{Timeout: 30 * time.Second} + + log.Printf("[resilience] starting HTTP stress barrage") + + type malformedReq struct { + name string + method string + url string + body []byte + } + + reqs := []malformedReq{ + {"empty-body", "POST", base + "/pdp/data-sets", nil}, + {"invalid-json", "POST", base + "/pdp/data-sets", []byte(`{not json!!!}`)}, + {"nonexistent-dataset", "GET", base + "/pdp/data-sets/99999999", nil}, + {"nonexistent-pieces", "GET", base + "/pdp/data-sets/99999999/pieces", nil}, + {"empty-piece-upload", "POST", base + "/pdp/piece/uploads", nil}, + {"invalid-piece-finalize", "POST", base + "/pdp/piece/uploads/00000000-0000-0000-0000-000000000000", + []byte(`{"pieceCid": "not-a-real-cid"}`)}, + {"huge-extra-data", "POST", base + "/pdp/data-sets", hugeExtraDataPayload()}, + } + + accepted := 0 + for _, r := range reqs { + var bodyReader io.Reader + if r.body != nil { + bodyReader = bytes.NewReader(r.body) + } + + req, err := http.NewRequestWithContext(ctx, r.method, r.url, bodyReader) + if err != nil { + continue + } + if r.body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(req) + if err != nil { + log.Printf("[resilience] %s: connection error (may be fine): %v", r.name, err) + continue + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + log.Printf("[resilience] %s: status=%d", r.name, resp.StatusCode) + accepted++ + } + + // THE KEY CHECK: Curio must still be alive after all the abuse + pingOK := foc.PingCurio(ctx) + assert.Always(pingOK, "Curio survives malformed HTTP requests", map[string]any{ + "requestsSent": len(reqs), + "accepted": accepted, + }) + + if !pingOK { + log.Printf("[resilience] CRITICAL: Curio not reachable after HTTP stress barrage!") + return + } + + assert.Sometimes(true, "Curio HTTP resilience exercised", map[string]any{ + "requestsSent": len(reqs), + }) + + resSecMu.Lock() + resSec.HTTPBarrages++ + resSecMu.Unlock() + + log.Printf("[resilience] HTTP barrage complete, Curio alive. Creating orphan dataset...") + + // Now create an empty dataset (orphan rail test) + if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { + focCfg.ReloadSPKey() + if focCfg.SPKey == nil { + return + } + } + + // Snapshot funds BEFORE + fundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) + metadataKeys := []string{"source"} + metadataValues := []string{"antithesis-resilience-orphan"} + + sig, err := foc.SignEIP712CreateDataSet( + gs.ClientKey, focCfg.FWSSAddr, + clientDataSetId, focCfg.SPEthAddr, + metadataKeys, metadataValues, + ) + if err != nil { + log.Printf("[resilience] EIP-712 signing failed: %v", err) + return + } + + extraData := encodeCreateDataSetExtra(gs.ClientEth, clientDataSetId, metadataKeys, metadataValues, sig) + recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) + + txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) + if err != nil { + log.Printf("[resilience] orphan dataset creation failed: %v", err) + return + } + + onChainID, err := foc.WaitForDataSetCreation(ctx, txHash) + if err != nil { + log.Printf("[resilience] orphan dataset confirmation failed: %v", err) + return + } + + log.Printf("[resilience] orphan dataset created: onChainID=%d (no pieces will be added)", onChainID) + + resSecMu.Lock() + resSec.OrphanDSID = onChainID + resSec.OrphanFundsBefore = fundsBefore + resSec.State = resOrphanCreated + resSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 2: Check Orphan Dataset Billing +// --------------------------------------------------------------------------- + +func resDoOrphanCheck() { + s := resSnap() + gs := griefSnap() + node := focNode() + + if s.OrphanDSID == 0 { + resSecMu.Lock() + resSec.State = resOrphanChecked + resSecMu.Unlock() + return + } + + // Verify zero pieces + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(s.OrphanDSID))) + activeCount, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + + // Check if dataset is live + live, _ := foc.EthCallBool(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigDataSetLive, dsIDBytes)) + + // Read current funds + fundsNow := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + log.Printf("[resilience] orphan dataset %d: live=%v activePieces=%v funds=%v (before=%v)", + s.OrphanDSID, live, activeCount, fundsNow, s.OrphanFundsBefore) + + // Check: with zero pieces, client should not be losing funds to storage charges + // (The sybil fee on creation is expected, but ongoing charges should be zero) + if activeCount != nil && activeCount.Sign() == 0 && fundsNow != nil && s.OrphanFundsBefore != nil { + // Allow for sybil fee deduction, but ongoing charges should not accumulate further + // We log this for observability — the sidecar rate-consistency check catches the invariant + assert.Sometimes(true, "Empty dataset billing checked", map[string]any{ + "dsID": s.OrphanDSID, + "activePieces": activeCount.String(), + "fundsBefore": s.OrphanFundsBefore.String(), + "fundsNow": fundsNow.String(), + }) + } + + resSecMu.Lock() + resSec.State = resOrphanChecked + resSecMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Phase 3: Cleanup — terminate and delete orphan dataset +// --------------------------------------------------------------------------- + +func resDoOrphanCleanup() { + s := resSnap() + + if s.OrphanDSID > 0 { + // We can't easily clean up without knowing the clientDataSetId. + // The orphan dataset was created with a random clientDataSetId that we didn't persist. + // For now, just log the orphan and move on. The sidecar will track it. + node := focNode() + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(s.OrphanDSID))) + live, _ := foc.EthCallBool(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigDataSetLive, dsIDBytes)) + log.Printf("[resilience] orphan dataset %d live=%v (left for sidecar monitoring)", s.OrphanDSID, live) + } + + resSecMu.Lock() + resSec.Cycles++ + cycles := resSec.Cycles + barrages := resSec.HTTPBarrages + resSec.State = resInit + resSec.OrphanDSID = 0 + resSec.OrphanFundsBefore = nil + resSecMu.Unlock() + + log.Printf("[resilience] cycle %d complete (HTTP barrages=%d)", cycles, barrages) + assert.Sometimes(true, "Resilience scenario cycle completes", map[string]any{ + "cycles": cycles, + "httpBarrages": barrages, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// hugeExtraDataPayload generates a large but valid-ish JSON body for stress testing. +func hugeExtraDataPayload() []byte { + // ~64KB of hex data + data := make([]byte, 32768) + for i := range data { + data[i] = byte(i & 0xFF) + } + hexStr := hex.EncodeToString(data) + return []byte(`{"recordKeeper":"0x0000000000000000000000000000000000000000","extraData":"` + hexStr + `"}`) +} + +// --------------------------------------------------------------------------- +// Progress +// --------------------------------------------------------------------------- + +func logResProgress() { + s := resSnap() + log.Printf("[resilience] state=%s cycles=%d httpBarrages=%d orphanDSID=%d", + s.State, s.Cycles, s.HTTPBarrages, s.OrphanDSID) +} diff --git a/workload/cmd/stress-engine/main.go b/workload/cmd/stress-engine/main.go index be19bdb6..e9aaeca3 100644 --- a/workload/cmd/stress-engine/main.go +++ b/workload/cmd/stress-engine/main.go @@ -301,6 +301,12 @@ func buildDeck() { weightedAction{"DoFOCDeleteDataSet", "STRESS_WEIGHT_FOC_DELETE_DS", DoFOCDeleteDataSet, 0}, // PDP griefing and economic assertion probes weightedAction{"DoPDPGriefingProbe", "STRESS_WEIGHT_PDP_GRIEFING", DoPDPGriefingProbe, 2}, + // Security scenario: full piece lifecycle + attacks + weightedAction{"DoFOCPieceSecurityProbe", "STRESS_WEIGHT_FOC_PIECE_SECURITY", DoFOCPieceSecurityProbe, 2}, + // Security scenario: rail payment lifecycle + audit findings + weightedAction{"DoFOCPaymentSecurityProbe", "STRESS_WEIGHT_FOC_PAYMENT_SECURITY", DoFOCPaymentSecurityProbe, 2}, + // Resilience scenario: Curio HTTP stress + orphan rails + weightedAction{"DoFOCResilienceProbe", "STRESS_WEIGHT_FOC_RESILIENCE", DoFOCResilienceProbe, 1}, // Cross-node receipt consistency (catches consensus divergence on EVM txs) weightedAction{"DoReceiptAudit", "STRESS_WEIGHT_RECEIPT_AUDIT", DoReceiptAudit, 1}, ) @@ -381,6 +387,9 @@ func main() { if focCfg != nil { logFOCProgress() logGriefProgress() + logPieceSecProgress() + logPaySecProgress() + logResProgress() } } } diff --git a/workload/internal/foc/eth.go b/workload/internal/foc/eth.go index adad6a00..645c4277 100644 --- a/workload/internal/foc/eth.go +++ b/workload/internal/foc/eth.go @@ -390,6 +390,24 @@ func ReadRailPaymentRate(ctx context.Context, node api.FullNode, filPayAddr []by return new(big.Int).SetBytes(result[160:192]) } +// ReadAllowance calls allowance(owner, spender) on an ERC-20 token. +func ReadAllowance(ctx context.Context, node api.FullNode, tokenAddr, ownerAddr, spenderAddr []byte) *big.Int { + calldata := BuildCalldata(SigAllowance, EncodeAddress(ownerAddr), EncodeAddress(spenderAddr)) + result, err := EthCallUint256(ctx, node, tokenAddr, calldata) + if err != nil { + log.Printf("[foc] ReadAllowance failed: %v", err) + return big.NewInt(0) + } + return result +} + +// ReadRailFull calls getRail(railId) and returns the full raw result (12 words / 384 bytes). +// Layout: token|from|to|operator|paymentRate|arbiter|createdEpoch|endEpoch|... +func ReadRailFull(ctx context.Context, node api.FullNode, filPayAddr []byte, railID uint64) ([]byte, error) { + calldata := BuildCalldata(SigGetRail, EncodeBigInt(new(big.Int).SetUint64(railID))) + return EthCallRaw(ctx, node, filPayAddr, calldata) +} + // EncodeBigInt ABI-encodes a *big.Int as a 32-byte big-endian uint256. func EncodeBigInt(n *big.Int) []byte { buf := make([]byte, 32) diff --git a/workload/internal/foc/selectors.go b/workload/internal/foc/selectors.go index 9152aae7..a5c6187a 100644 --- a/workload/internal/foc/selectors.go +++ b/workload/internal/foc/selectors.go @@ -29,6 +29,7 @@ var ( SigModifyRailPayment = CalcSelector("modifyRailPayment(uint256,uint256,uint256)") SigGetRail = CalcSelector("getRail(uint256)") + SigAllowance = CalcSelector("allowance(address,address)") // ServiceProviderRegistry SigAddrToProvId = CalcSelector("addressToProviderId(address)") From cb558b66931160d9ab24e3a373ec9893b2ec931c Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Tue, 7 Apr 2026 12:44:02 +0000 Subject: [PATCH 5/8] feat(foc): security scenario restructure + audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructured FOC security testing from flat probe functions into coherent scenario-driven state machines and independent dispatch probes. Fixed multiple critical bugs found during code audit. Security scenarios (new files): - foc_piece_security.go: 5-phase piece lifecycle (add→verify→delete→check→attack) with 5 attack probes (nonce replay, cross-dataset injection, double deletion, nonexistent deletion, post-termination addition) - foc_payment_security.go: 7 independent payment/rail probes (settlement lockup L01, double-settle, withdrawTo redirect, unauthorized deposit L04, direct rail terminate, settle-terminated escape hatch, full withdrawal #288) - foc_resilience.go: Curio HTTP stress barrage + orphan rail billing check Bug fixes: - C1: piece security stuck in infinite attack loop (state transition to self) - C2: piece security cleanup corrupted shared griefing state - H1: unified log tags to [foc-*] pattern across all FOC files - H5: DoFOCWithdraw computed from total funds instead of available (funds-lockup) - H7: DoFOCDeletePiece lost piece from state on tx failure (no rollback) - H8: insolvency refund ignored errors, permanently draining secondary client Griefing improvements: - First dispatch forced to EmptyDatasetFee (sets LastOnChainDSID for other scenarios) - After initial dataset, only non-destructive probes run (CrossPayerReplay, BurstCreation) - Cooldown between dispatches (200 epochs) prevents fund starvation - Removed unused griefRuntime fields, dead code (buildCreateDataSetCalldata) Sidecar additions: - checkLockupNeverExceedsFunds: assert.Always lockup <= funds for all payers - checkDeletedDatasetRailTerminated: verify deleted dataset rails have endEpoch set Infrastructure: - New selectors: SigTerminateRail, SigWithdrawTo, SigSettleTerminatedRailNoValidation, SigModifyRailLockup, SigAllowance - New helpers: ReadAllowance, ReadRailFull in eth.go - Fork monitor poll interval configurable via FORK_POLL_INTERVAL_SECS (30s for FOC runs) Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yaml | 123 ++-- .../cmd/stress-engine/consensus_vectors.go | 8 +- .../cmd/stress-engine/foc_payment_security.go | 655 ++++++++---------- .../cmd/stress-engine/foc_piece_security.go | 483 ++++--------- workload/cmd/stress-engine/foc_resilience.go | 26 +- workload/cmd/stress-engine/foc_vectors.go | 35 +- .../cmd/stress-engine/griefing_vectors.go | 233 +++++-- workload/cmd/stress-engine/main.go | 6 +- workload/internal/foc/selectors.go | 4 + 9 files changed, 709 insertions(+), 864 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index f4902b63..adb370e5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -138,83 +138,66 @@ services: container_name: workload environment: - STRESS_NODES=lotus0,lotus1,lotus2,lotus3 + - FORK_POLL_INTERVAL_SECS=30 - STRESS_RPC_PORT=1234 - STRESS_FOREST_RPC_PORT=3456 - STRESS_KEYSTORE_PATH=/shared/configs/stress_keystore.json - STRESS_WAIT_HEIGHT=10 - # --- Consensus / health-check vectors (always active in both profiles) --- - - STRESS_WEIGHT_TIPSET_CONSENSUS=3 # cross-node tipset agreement (Sometimes) - - STRESS_WEIGHT_HEIGHT_PROGRESSION=2 # chain height advances - - STRESS_WEIGHT_PEER_COUNT=1 # node peer connectivity - - STRESS_WEIGHT_HEAD_COMPARISON=3 # cross-node chain head match (Sometimes) - - STRESS_WEIGHT_STATE_ROOT=4 # cross-node state root match (Sometimes) - - STRESS_WEIGHT_STATE_AUDIT=3 # full state tree audit - - STRESS_WEIGHT_F3_MONITOR=2 # passive F3 health monitor - - STRESS_WEIGHT_F3_AGREEMENT=3 # cross-node F3 certificate consistency - - STRESS_WEIGHT_DRAND_BEACON_AUDIT=3 # cross-node drand beacon entry consistency - - STRESS_WEIGHT_REORG=0 # power-aware reorg testing (disabled — consensus lifecycle handles partitions) - - STRESS_WEIGHT_POWER_SLASH=4 # power-aware miner slashing - # - STRESS_WEIGHT_QUORUM_STALL=0 # deliberate F3 stall (opt-in, destructive) - - FUZZER_ENABLED=0 # protocol fuzzer off for consensus threshold testing + # --- Consensus / health-check vectors (zeroed for new-vector-only testing) --- + - STRESS_WEIGHT_TIPSET_CONSENSUS=0 + - STRESS_WEIGHT_HEIGHT_PROGRESSION=0 + - STRESS_WEIGHT_PEER_COUNT=0 + - STRESS_WEIGHT_HEAD_COMPARISON=0 + - STRESS_WEIGHT_STATE_ROOT=0 + - STRESS_WEIGHT_STATE_AUDIT=0 + - STRESS_WEIGHT_F3_MONITOR=0 + - STRESS_WEIGHT_F3_AGREEMENT=0 + - STRESS_WEIGHT_REORG=0 + - STRESS_WEIGHT_POWER_SLASH=0 + - FUZZER_ENABLED=0 + - STRESS_CONSENSUS_TEST=0 # - # --- Consensus integration test (background lifecycle, not deck) --- - - STRESS_CONSENSUS_TEST=1 # enable structured EC/F3 safety proof lifecycle - # - # --- Non-FOC stress vectors --- - # - # EVM contract stress - - STRESS_WEIGHT_DEPLOY=1 # Init actor & state tree growth via EAM.CreateExternal - - STRESS_WEIGHT_CONTRACT_CALL=1 # deep recursion, delegatecall, external recursive calls - - STRESS_WEIGHT_SELFDESTRUCT=0 # actor destruction state consistency across nodes - - STRESS_WEIGHT_CONTRACT_RACE=2 # conflicting txs to diff nodes — state divergence during forks - # Background chain activity (deck — runs between test cycles) - - STRESS_WEIGHT_TRANSFER=2 # FIL transfers (state changes for forks to reconcile) - - STRESS_WEIGHT_GAS_WAR=1 # mempool replacement across forks - - STRESS_WEIGHT_NONCE_RACE=1 # gas-premium race across different nodes - - STRESS_WEIGHT_HEAVY_COMPUTE=1 # intra-node state recomputation verification - # Resource stress (disabled) - - STRESS_WEIGHT_MAX_BLOCK_GAS=0 # maxes out block gas - - STRESS_WEIGHT_LOG_BLASTER=0 # receipt storage, bloom filters, event indexing - - STRESS_WEIGHT_MEMORY_BOMB=0 # FVM memory accounting - - STRESS_WEIGHT_STORAGE_SPAM=0 # state trie (HAMT), SplitStore - # Nonce/ordering chaos - - STRESS_WEIGHT_MSG_ORDERING=1 # cross-node message replacement/mempool ordering - - STRESS_WEIGHT_NONCE_BOMBARD=0 # N+x gap handling & out-of-order execution - - STRESS_WEIGHT_ADVERSARIAL=0 # double-spends (handled by consensus lifecycle) - # Gas pressure (disabled) - - STRESS_WEIGHT_GAS_EXHAUST=0 # high-gas call competing with small msgs - # Cross-node consistency - - STRESS_WEIGHT_RECEIPT_AUDIT=4 # asserts receipt fields match across every node - - STRESS_WEIGHT_ACTOR_MIGRATION=1 # burst-creates & deletes actors, stresses HAMT during forks - - STRESS_WEIGHT_ACTOR_LIFECYCLE=1 # full actor lifecycle - # --- FOC (Filecoin On-Chain Cloud) vectors --- - # All FOC vectors require the `foc` compose profile to be active. - # The lifecycle state machine must reach "Ready" before steady-state - # vectors will fire. Higher weight = picked more often from the deck. - # - # SETUP: drives the sequential state machine one step per pick - # Init → Approved → Deposited → OperatorApproved → DataSetCreated → Ready + # --- Non-FOC stress vectors (all zeroed) --- + - STRESS_WEIGHT_DEPLOY=0 + - STRESS_WEIGHT_CONTRACT_CALL=0 + - STRESS_WEIGHT_SELFDESTRUCT=0 + - STRESS_WEIGHT_CONTRACT_RACE=0 + - STRESS_WEIGHT_TRANSFER=0 + - STRESS_WEIGHT_GAS_WAR=0 + - STRESS_WEIGHT_NONCE_RACE=0 + - STRESS_WEIGHT_HEAVY_COMPUTE=0 + - STRESS_WEIGHT_MAX_BLOCK_GAS=0 + - STRESS_WEIGHT_LOG_BLASTER=0 + - STRESS_WEIGHT_MEMORY_BOMB=0 + - STRESS_WEIGHT_STORAGE_SPAM=0 + - STRESS_WEIGHT_MSG_ORDERING=0 + - STRESS_WEIGHT_NONCE_BOMBARD=0 + - STRESS_WEIGHT_ADVERSARIAL=0 + - STRESS_WEIGHT_GAS_EXHAUST=0 + - STRESS_WEIGHT_RECEIPT_AUDIT=0 + - STRESS_WEIGHT_ACTOR_MIGRATION=0 + - STRESS_WEIGHT_ACTOR_LIFECYCLE=0 + # --- FOC vectors --- + # REQUIRED: lifecycle must reach Ready + griefing must set up secondary client - STRESS_WEIGHT_FOC_LIFECYCLE=6 + # Minimum steady-state: need pieces uploaded/added for piece security scenario + - STRESS_WEIGHT_FOC_UPLOAD=2 + - STRESS_WEIGHT_FOC_ADD_PIECES=2 + - STRESS_WEIGHT_FOC_MONITOR=1 # keep one for observability + - STRESS_WEIGHT_FOC_RETRIEVE=0 + - STRESS_WEIGHT_FOC_TRANSFER=0 + - STRESS_WEIGHT_FOC_SETTLE=0 + - STRESS_WEIGHT_FOC_WITHDRAW=0 + - STRESS_WEIGHT_FOC_DELETE_PIECE=0 + - STRESS_WEIGHT_FOC_DELETE_DS=0 + - STRESS_WEIGHT_REORG_CHAOS=0 # disable — partitions lotus0 which stalls all FOC ops # - # STEADY-STATE: only execute once lifecycle reaches Ready - - STRESS_WEIGHT_FOC_UPLOAD=4 # upload random data to Curio PDP API - - STRESS_WEIGHT_FOC_ADD_PIECES=3 # add uploaded pieces to on-chain proofset - - STRESS_WEIGHT_FOC_MONITOR=4 # query proofset health + USDFC balances - - STRESS_WEIGHT_FOC_RETRIEVE=2 # download piece and verify CID integrity - - STRESS_WEIGHT_FOC_TRANSFER=2 # ERC-20 USDFC transfer (client → deployer) - - STRESS_WEIGHT_FOC_SETTLE=2 # settle active payment rail - - STRESS_WEIGHT_FOC_WITHDRAW=2 # withdraw USDFC from FilecoinPay - # - # DESTRUCTIVE: weight 0 = disabled by default, set >0 to opt-in - - STRESS_WEIGHT_FOC_DELETE_PIECE=1 # schedule piece deletion from proofset - - STRESS_WEIGHT_FOC_DELETE_DS=0 # delete entire dataset + reset lifecycle - # - # ADVERSARIAL: economic security + griefing probes - - STRESS_WEIGHT_PDP_GRIEFING=8 # fee extraction, insolvency, replay, burst attacks - # SECURITY SCENARIOS: piece lifecycle, payment rail, resilience - - STRESS_WEIGHT_FOC_PIECE_SECURITY=2 # piece lifecycle + attack probes (nonce replay, cross-DS, double-delete) - - STRESS_WEIGHT_FOC_PAYMENT_SECURITY=2 # rail settlement + audit L01/L04/L06/#288 - - STRESS_WEIGHT_FOC_RESILIENCE=1 # Curio HTTP stress + orphan rail billing + # REQUIRED: griefing setup must complete before security scenarios fire + - STRESS_WEIGHT_PDP_GRIEFING=6 + # *** NEW VECTORS UNDER TEST *** + - STRESS_WEIGHT_FOC_PIECE_SECURITY=4 # piece lifecycle + attack probes + - STRESS_WEIGHT_FOC_PAYMENT_SECURITY=4 # rail settlement + audit L01/L04/L06/#288 + - STRESS_WEIGHT_FOC_RESILIENCE=3 # Curio HTTP stress + orphan rail # - CURIO_PDP_URL=http://curio:80 diff --git a/workload/cmd/stress-engine/consensus_vectors.go b/workload/cmd/stress-engine/consensus_vectors.go index b477f968..15c60087 100644 --- a/workload/cmd/stress-engine/consensus_vectors.go +++ b/workload/cmd/stress-engine/consensus_vectors.go @@ -625,13 +625,17 @@ const ( // this many epochs, it's a persistent fork (real bug). forkConvergenceBuffer = 50 - // forkPollInterval is how often the background goroutine checks for forks. - forkPollInterval = 5 * time.Second + // forkPollIntervalDefault is the fallback if env var is not set. + forkPollIntervalDefault = 5 // forkMaxTracked limits memory usage for tracked forks. forkMaxTracked = 100 ) +// forkPollInterval is configurable via FORK_POLL_INTERVAL_SECS. Set higher +// (e.g. 30) for FOC runs where fork detection is less critical. +var forkPollInterval = time.Duration(envInt("FORK_POLL_INTERVAL_SECS", forkPollIntervalDefault)) * time.Second + // trackedFork records a detected disagreement for later re-verification. type trackedFork struct { height abi.ChainEpoch // height where disagreement was observed diff --git a/workload/cmd/stress-engine/foc_payment_security.go b/workload/cmd/stress-engine/foc_payment_security.go index 84faa65d..8fc3fc7c 100644 --- a/workload/cmd/stress-engine/foc_payment_security.go +++ b/workload/cmd/stress-engine/foc_payment_security.go @@ -8,106 +8,45 @@ import ( "workload/internal/foc" "github.com/antithesishq/antithesis-sdk-go/assert" - "github.com/antithesishq/antithesis-sdk-go/random" ) // =========================================================================== -// Scenario 2: Payment Rail & Funds Security +// FOC Payment & Rail Security Probes // -// Tests the full payment rail lifecycle with audit finding checks at each step. -// Each deck invocation advances one phase. The scenario cycles continuously: +// Independent probes that test economic invariants of FilecoinPay and rail +// lifecycle. Each invocation picks one probe at random — no artificial +// sequential dependencies. // -// Init → Settled → DoubleSettled → RailChecked → RateModified → -// Withdrawn → Refunded → (back to Init) +// Prerequisites: griefRuntime must be in griefReady state (secondary client +// wallet funded, f4 actor created, FWSS operator approved). At least one +// dataset must exist (griefRT.LastOnChainDSID > 0) so rails are available. // -// Covers: -// - Audit L01: lockup accounting after settlement -// - Audit L04: unauthorized third-party deposit -// - Audit L06: rate change queue staleness -// - Issue #288: funds locked after lifecycle +// Probes: +// - Settlement lockup accounting (Audit L01) // - Double settlement idempotency -// - 3-rail sanity check (no FILCDN/IPNI billing) -// -// Requires griefRuntime to be in griefReady state (secondary client set up). +// - withdrawTo redirect attack +// - Unauthorized third-party deposit (Audit L04) +// - Direct rail termination bypassing FWSS +// - settleTerminatedRailWithoutValidation escape hatch +// - Full withdrawal after settle (Issue #288) // =========================================================================== -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -type paySecState int - -const ( - paySecInit paySecState = iota // snapshot funds/lockup, discover rails - paySecSettled // settle pdpRail, verify lockup (L01) - paySecDoubleSettled // settle same rail again, verify idempotent - paySecRailChecked // verify all 3 rails configuration - paySecRateModified // modify rate twice, verify latest persists (L06) - paySecWithdrawn // withdraw all available funds (#288) - paySecRefunded // re-deposit + test unauthorized deposit (L04) -) - -func (s paySecState) String() string { - switch s { - case paySecInit: - return "Init" - case paySecSettled: - return "Settled" - case paySecDoubleSettled: - return "DoubleSettled" - case paySecRailChecked: - return "RailChecked" - case paySecRateModified: - return "RateModified" - case paySecWithdrawn: - return "Withdrawn" - case paySecRefunded: - return "Refunded" - default: - return "Unknown" - } -} - var ( - paySec paySecRuntime - paySecMu sync.Mutex + payProbesMu sync.Mutex + payProbeCount int ) -type paySecRuntime struct { - State paySecState - - // Snapshot values - FundsBefore *big.Int - LockupBefore *big.Int - FundsAfter *big.Int - LockupAfter *big.Int - - // Rail discovery - RailID *big.Int - SettleEpoch *big.Int - - // Progress - Cycles int -} - -func paySecSnap() paySecRuntime { - paySecMu.Lock() - defer paySecMu.Unlock() - return paySec -} - // --------------------------------------------------------------------------- -// DoFOCPaymentSecurityProbe — deck entry +// DoFOCPaymentSecurity — deck entry, dispatches one random probe // --------------------------------------------------------------------------- -func DoFOCPaymentSecurityProbe() { +func DoFOCPaymentSecurity() { if focCfg == nil || focCfg.ClientKey == nil { return } if _, ok := requireReady(); !ok { return } - gs := griefSnap() if gs.State != griefReady || gs.ClientKey == nil { return @@ -116,320 +55,371 @@ func DoFOCPaymentSecurityProbe() { return } - paySecMu.Lock() - state := paySec.State - paySecMu.Unlock() - - switch state { - case paySecInit: - paySecDoInit() - case paySecSettled: - paySecDoDoubleSettle() - case paySecDoubleSettled: - paySecDoRailCheck() - case paySecRailChecked: - paySecDoRateModify() - case paySecRateModified: - paySecDoWithdraw() - case paySecWithdrawn: - paySecDoRefund() - case paySecRefunded: - // Reset - paySecMu.Lock() - paySec.Cycles++ - cycles := paySec.Cycles - paySec.State = paySecInit - paySec.RailID = nil - paySec.SettleEpoch = nil - paySec.FundsBefore = nil - paySec.LockupBefore = nil - paySec.FundsAfter = nil - paySec.LockupAfter = nil - paySecMu.Unlock() - log.Printf("[payment-security] cycle %d complete, resetting", cycles) - assert.Sometimes(true, "Payment security scenario cycle completes", map[string]any{ - "cycles": cycles, - }) + // Need at least one dataset (and therefore rails) to exist + if gs.LastOnChainDSID == 0 { + return + } + + type probe struct { + name string + fn func(griefRuntime) + } + probes := []probe{ + {"SettleLockup", payProbeSettleLockup}, + {"DoubleSettle", payProbeDoubleSettle}, + {"WithdrawToRedirect", payProbeWithdrawToRedirect}, + {"UnauthorizedDeposit", payProbeUnauthorizedDeposit}, + {"DirectTerminateRail", payProbeDirectTerminateRail}, + {"SettleTerminatedRail", payProbeSettleTerminatedRail}, + {"WithdrawAll", payProbeWithdrawAll}, } + + pick := probes[rngIntn(len(probes))] + log.Printf("[foc-payment-security] probe: %s", pick.name) + pick.fn(gs) + + payProbesMu.Lock() + payProbeCount++ + payProbesMu.Unlock() } // --------------------------------------------------------------------------- -// Phase 1: Snapshot + Settle (Audit L01) +// Helpers shared across probes // --------------------------------------------------------------------------- -func paySecDoInit() { - gs := griefSnap() +// payFindRail discovers the first rail for the secondary client. +// Returns nil if no rails found. +func payFindRail(gs griefRuntime) *big.Int { node := focNode() - - // Read funds/lockup BEFORE - funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - - if funds == nil || funds.Sign() == 0 { - log.Printf("[payment-security] secondary client has no funds, skipping") - return - } - - // Discover rails for secondary client - railCalldata := foc.BuildCalldata(foc.SigGetRailsByPayer, + calldata := foc.BuildCalldata(foc.SigGetRailsByPayer, foc.EncodeAddress(gs.ClientEth), foc.EncodeAddress(focCfg.USDFCAddr), foc.EncodeBigInt(big.NewInt(0)), foc.EncodeBigInt(big.NewInt(10)), ) - result, err := foc.EthCallRaw(ctx, node, focCfg.FilPayAddr, railCalldata) + result, err := foc.EthCallRaw(ctx, node, focCfg.FilPayAddr, calldata) if err != nil || len(result) < 96 { - log.Printf("[payment-security] no rails found for secondary client") - return + return nil } - arrayLen := new(big.Int).SetBytes(result[32:64]) if arrayLen.Sign() == 0 { - log.Printf("[payment-security] no rails found (empty array)") - return + return nil } - railID := new(big.Int).SetBytes(result[64:96]) + return new(big.Int).SetBytes(result[64:96]) +} - // Get current epoch for settlement +// payProvingPeriodElapsed checks if at least one proving period has passed +// for the griefing dataset. Settlement reverts if called mid-period. +func payProvingPeriodElapsed(gs griefRuntime) bool { + if gs.LastOnChainDSID == 0 || focCfg.PDPAddr == nil { + return false + } + node := focNode() head, err := node.ChainHead(ctx) if err != nil { + return false + } + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + nextChallenge, err := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, + foc.BuildCalldata(foc.SigGetNextChallengeEpoch, dsIDBytes)) + if err != nil || nextChallenge == nil || nextChallenge.Sign() == 0 { + return false + } + return int64(head.Height()) >= nextChallenge.Int64() +} + +// --------------------------------------------------------------------------- +// Probe: Settlement Lockup Accounting (Audit L01) +// +// After settling a rail, lockup must not increase. The audit found that +// lockup was not properly decremented during finalization. +// --------------------------------------------------------------------------- + +func payProbeSettleLockup(gs griefRuntime) { + railID := payFindRail(gs) + if railID == nil { + log.Printf("[foc-payment-security] no rails found for secondary client") + return + } + if !payProvingPeriodElapsed(gs) { + log.Printf("[foc-payment-security] waiting for proving period before settlement") return } + + node := focNode() + + lockupBefore := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + head, _ := node.ChainHead(ctx) settleEpoch := big.NewInt(int64(head.Height())) - // Settle the rail - settleCalldata := foc.BuildCalldata(foc.SigSettleRail, + calldata := foc.BuildCalldata(foc.SigSettleRail, foc.EncodeBigInt(railID), foc.EncodeBigInt(settleEpoch), ) - ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, settleCalldata, "payment-security-settle") + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-settle") if !ok { - log.Printf("[payment-security] settlement failed for railID=%s, will retry", railID) + log.Printf("[foc-payment-security] settle failed for railID=%s", railID) return } - // Read funds/lockup AFTER - fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) lockupAfter := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - // Audit L01: lockup must not increase after settlement - if lockup != nil && lockupAfter != nil { - noIncrease := lockupAfter.Cmp(lockup) <= 0 + if lockupBefore != nil && lockupAfter != nil { + noIncrease := lockupAfter.Cmp(lockupBefore) <= 0 assert.Sometimes(noIncrease, "Lockup does not increase after settlement", map[string]any{ - "lockupBefore": lockup.String(), + "lockupBefore": lockupBefore.String(), "lockupAfter": lockupAfter.String(), "railID": railID.String(), - "settleEpoch": settleEpoch.String(), }) if !noIncrease { - log.Printf("[payment-security] ANOMALY: lockup INCREASED after settlement: %s → %s", lockup, lockupAfter) + log.Printf("[foc-payment-security] ANOMALY: lockup increased after settlement: %s → %s", lockupBefore, lockupAfter) } } - log.Printf("[payment-security] settled railID=%s epoch=%s funds=%s→%s lockup=%s→%s", - railID, settleEpoch, funds, fundsAfter, lockup, lockupAfter) - - paySecMu.Lock() - paySec.FundsBefore = funds - paySec.LockupBefore = lockup - paySec.FundsAfter = fundsAfter - paySec.LockupAfter = lockupAfter - paySec.RailID = railID - paySec.SettleEpoch = settleEpoch - paySec.State = paySecSettled - paySecMu.Unlock() + log.Printf("[foc-payment-security] settled railID=%s lockup=%v→%v", railID, lockupBefore, lockupAfter) } // --------------------------------------------------------------------------- -// Phase 2: Double Settlement — verify idempotent +// Probe: Double Settlement Idempotency +// +// Settling the same rail to the same epoch twice must not double-deduct funds. // --------------------------------------------------------------------------- -func paySecDoDoubleSettle() { - s := paySecSnap() - gs := griefSnap() - node := focNode() - - if s.RailID == nil || s.SettleEpoch == nil { - paySecMu.Lock() - paySec.State = paySecDoubleSettled - paySecMu.Unlock() +func payProbeDoubleSettle(gs griefRuntime) { + railID := payFindRail(gs) + if railID == nil { + return + } + if !payProvingPeriodElapsed(gs) { return } - // Read funds before second settle - fundsBefore2 := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + node := focNode() + head, _ := node.ChainHead(ctx) + settleEpoch := big.NewInt(int64(head.Height())) - // Settle same rail at same epoch again - settleCalldata := foc.BuildCalldata(foc.SigSettleRail, - foc.EncodeBigInt(s.RailID), - foc.EncodeBigInt(s.SettleEpoch), + calldata := foc.BuildCalldata(foc.SigSettleRail, + foc.EncodeBigInt(railID), + foc.EncodeBigInt(settleEpoch), ) - foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, settleCalldata, "payment-security-double-settle") - // Read funds after second settle - fundsAfter2 := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + // First settle + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-settle1") + + // Snapshot between settles + fundsBetween := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + // Second settle — same rail, same epoch + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-settle2") - if fundsBefore2 != nil && fundsAfter2 != nil { - noExtraDeduction := fundsAfter2.Cmp(fundsBefore2) >= 0 - assert.Sometimes(noExtraDeduction, "Double settlement is idempotent", map[string]any{ - "fundsBefore2": fundsBefore2.String(), - "fundsAfter2": fundsAfter2.String(), - "railID": s.RailID.String(), + fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) + + if fundsBetween != nil && fundsAfter != nil { + noExtraDeduction := fundsAfter.Cmp(fundsBetween) >= 0 + assert.Sometimes(noExtraDeduction, "Double settlement does not double-deduct", map[string]any{ + "fundsBetween": fundsBetween.String(), + "fundsAfter": fundsAfter.String(), + "railID": railID.String(), }) if !noExtraDeduction { - delta := new(big.Int).Sub(fundsBefore2, fundsAfter2) - log.Printf("[payment-security] ANOMALY: double settle caused extra deduction of %s", delta) + log.Printf("[foc-payment-security] ANOMALY: double settle deducted extra: %s → %s", fundsBetween, fundsAfter) } } +} + +// --------------------------------------------------------------------------- +// Probe: withdrawTo Redirect Attack +// +// Attacker (secondary client) calls withdrawTo(USDFC, attackerAddr, amount). +// Verify it only withdraws from the caller's own account — the `to` param +// is the recipient, not the source. Must never drain another user's funds. +// --------------------------------------------------------------------------- + +func payProbeWithdrawToRedirect(gs griefRuntime) { + node := focNode() + + // Snapshot primary client's funds BEFORE + primaryFundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + + smallAmount := big.NewInt(1000000000000000) // 0.001 USDFC + calldata := foc.BuildCalldata(foc.SigWithdrawTo, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(gs.ClientEth), // recipient = attacker + foc.EncodeBigInt(smallAmount), + ) + + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-withdrawto") - log.Printf("[payment-security] double settle complete: funds=%v→%v", fundsBefore2, fundsAfter2) + // Verify primary client's funds were NOT affected + primaryFundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - paySecMu.Lock() - paySec.State = paySecDoubleSettled - paySecMu.Unlock() + if primaryFundsBefore != nil && primaryFundsAfter != nil { + primaryDrained := primaryFundsAfter.Cmp(primaryFundsBefore) < 0 + if primaryDrained && ok { + log.Printf("[foc-payment-security] CRITICAL: withdrawTo drained PRIMARY funds! %s → %s", primaryFundsBefore, primaryFundsAfter) + } + assert.Sometimes(!primaryDrained, "withdrawTo does not drain other user funds", map[string]any{ + "primaryBefore": primaryFundsBefore.String(), + "primaryAfter": primaryFundsAfter.String(), + "ok": ok, + }) + } } // --------------------------------------------------------------------------- -// Phase 3: Rail Sanity Check — verify 3-rail configuration +// Probe: Unauthorized Third-Party Deposit (Audit L04) +// +// Attacker deposits tokens into the PRIMARY client's FilecoinPay account +// without their consent. Verify funds don't increase for the target. // --------------------------------------------------------------------------- -func paySecDoRailCheck() { - gs := griefSnap() +func payProbeUnauthorizedDeposit(gs griefRuntime) { node := focNode() - if gs.LastOnChainDSID == 0 { - paySecMu.Lock() - paySec.State = paySecRailChecked - paySecMu.Unlock() - return - } + primaryFundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - // Read the pdpRailId payment rate (should be non-zero for active dataset) - s := paySecSnap() - if s.RailID != nil { - rate := foc.ReadRailPaymentRate(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) - if rate != nil { - log.Printf("[payment-security] pdpRail rate=%s", rate) - } + smallAmount := big.NewInt(1000000000000000) // 0.001 USDFC + calldata := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(focCfg.ClientEthAddr), // target: PRIMARY client + foc.EncodeBigInt(smallAmount), + ) + + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-unauth-deposit") + + primaryFundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - // Read the full rail to check endEpoch (should be 0 for active rail) - railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) - if err == nil && len(railData) >= 256 { - endEpoch := new(big.Int).SetBytes(railData[224:256]) // word index 7 - log.Printf("[payment-security] pdpRail endEpoch=%s (0=active)", endEpoch) + if primaryFundsBefore != nil && primaryFundsAfter != nil { + inflated := primaryFundsAfter.Cmp(primaryFundsBefore) > 0 + assert.Always(!inflated || !ok, "Third-party deposit cannot inflate target account", map[string]any{ + "primaryBefore": primaryFundsBefore.String(), + "primaryAfter": primaryFundsAfter.String(), + "depositOK": ok, + }) + if inflated && ok { + log.Printf("[foc-payment-security] CRITICAL: unauthorized deposit inflated primary: %s → %s", primaryFundsBefore, primaryFundsAfter) } } +} - // Check active piece count — if zero and rate > 0, billing for empty storage - dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) - activeCount, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) +// --------------------------------------------------------------------------- +// Probe: Direct Rail Termination Bypassing FWSS +// +// Calls terminateRail directly on FilecoinPay instead of going through +// FWSS.terminateService. Tests access control — only the rail's payer +// or operator should be allowed to terminate. +// --------------------------------------------------------------------------- - if activeCount != nil && s.RailID != nil { - rate := foc.ReadRailPaymentRate(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) - if rate != nil && activeCount.Sign() == 0 && rate.Sign() > 0 { - log.Printf("[payment-security] NOTE: zero pieces but non-zero rate=%s — may be expected during setup", rate) - } +func payProbeDirectTerminateRail(gs griefRuntime) { + railID := payFindRail(gs) + if railID == nil { + return } - log.Printf("[payment-security] rail check complete: dsID=%d activePieces=%v", gs.LastOnChainDSID, activeCount) + node := focNode() + + // Check if already terminated + railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, railID.Uint64()) + if err != nil || len(railData) < 256 { + return + } + endEpoch := new(big.Int).SetBytes(railData[224:256]) + if endEpoch.Sign() > 0 { + log.Printf("[foc-payment-security] rail %s already terminated (endEpoch=%s), skipping", railID, endEpoch) + return + } - paySecMu.Lock() - paySec.State = paySecRailChecked - paySecMu.Unlock() + calldata := foc.BuildCalldata(foc.SigTerminateRail, + foc.EncodeBigInt(railID), + ) + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-terminate-rail") + + if ok { + railAfter, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, railID.Uint64()) + if err == nil && len(railAfter) >= 256 { + endEpochAfter := new(big.Int).SetBytes(railAfter[224:256]) + assert.Sometimes(endEpochAfter.Sign() > 0, "Direct rail termination sets endEpoch", map[string]any{ + "railID": railID.String(), + "endEpoch": endEpochAfter.String(), + }) + log.Printf("[foc-payment-security] direct terminateRail succeeded: railID=%s endEpoch=%s", railID, endEpochAfter) + } + } else { + log.Printf("[foc-payment-security] direct terminateRail reverted for railID=%s (access control working)", railID) + assert.Sometimes(true, "Direct rail termination access control exercised", map[string]any{ + "railID": railID.String(), + }) + } } // --------------------------------------------------------------------------- -// Phase 4: Rate Modification (Audit L06) +// Probe: Settle Terminated Rail Without Validation (escape hatch) +// +// settleTerminatedRailWithoutValidation bypasses the FWSS validator. +// This exists for when the validator contract is broken. Verify lockup +// is released after the escape settlement. // --------------------------------------------------------------------------- -func paySecDoRateModify() { - s := paySecSnap() - gs := griefSnap() +func payProbeSettleTerminatedRail(gs griefRuntime) { + railID := payFindRail(gs) + if railID == nil { + return + } + node := focNode() - if s.RailID == nil { - paySecMu.Lock() - paySec.State = paySecRateModified - paySecMu.Unlock() + // Only works on terminated rails (endEpoch > 0) + railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, railID.Uint64()) + if err != nil || len(railData) < 256 { + return + } + endEpoch := new(big.Int).SetBytes(railData[224:256]) + if endEpoch.Sign() == 0 { + // Rail not terminated — this probe doesn't apply return } - // Generate two different rates - rate1 := new(big.Int).SetUint64(random.GetRandom()%1000 + 1) - rate2 := new(big.Int).SetUint64(random.GetRandom()%1000 + 1001) // guaranteed different - lockupPeriod := big.NewInt(3600) + lockupBefore := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - // First rate change - calldata1 := foc.BuildCalldata(foc.SigModifyRailPayment, - foc.EncodeBigInt(s.RailID), - foc.EncodeBigInt(rate1), - foc.EncodeBigInt(lockupPeriod), + calldata := foc.BuildCalldata(foc.SigSettleTerminatedRailNoValidation, + foc.EncodeBigInt(railID), ) - ok1 := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata1, "payment-security-rate1") + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-settle-terminated") - if !ok1 { - log.Printf("[payment-security] rate change 1 failed (may not have permission), skipping L06 test") - paySecMu.Lock() - paySec.State = paySecRateModified - paySecMu.Unlock() - return - } + lockupAfter := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - // Second rate change (should overwrite first) - calldata2 := foc.BuildCalldata(foc.SigModifyRailPayment, - foc.EncodeBigInt(s.RailID), - foc.EncodeBigInt(rate2), - foc.EncodeBigInt(lockupPeriod), - ) - ok2 := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata2, "payment-security-rate2") - - if ok1 && ok2 { - // Read the rail to check which rate persisted - railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, s.RailID.Uint64()) - if err == nil && len(railData) >= 192 { - currentRate := new(big.Int).SetBytes(railData[128:160]) // word index 4 = paymentRate - log.Printf("[payment-security] after two rate changes: currentRate=%s rate1=%s rate2=%s", currentRate, rate1, rate2) - - // The latest rate (rate2) should be the one that persists - latestPersists := currentRate.Cmp(rate1) != 0 - assert.Sometimes(latestPersists, "Latest rate change replaces stale pending change", map[string]any{ - "rate1": rate1.String(), - "rate2": rate2.String(), - "currentRate": currentRate.String(), - "railID": s.RailID.String(), - }) - } + if ok && lockupBefore != nil && lockupAfter != nil { + lockupDecreased := lockupAfter.Cmp(lockupBefore) <= 0 + assert.Sometimes(lockupDecreased, "Lockup decreases after settling terminated rail", map[string]any{ + "lockupBefore": lockupBefore.String(), + "lockupAfter": lockupAfter.String(), + "railID": railID.String(), + }) + log.Printf("[foc-payment-security] settleTerminatedRail: railID=%s lockup=%v→%v", railID, lockupBefore, lockupAfter) + } else if !ok { + log.Printf("[foc-payment-security] settleTerminatedRail reverted for railID=%s endEpoch=%s", railID, endEpoch) } - - paySecMu.Lock() - paySec.State = paySecRateModified - paySecMu.Unlock() } // --------------------------------------------------------------------------- -// Phase 5: Withdraw All Available Funds (#288) +// Probe: Full Withdrawal After Settlement (Issue #288) +// +// After settling, available = funds - lockup should be withdrawable. +// If withdrawal reverts when available > 0, funds are permanently locked. // --------------------------------------------------------------------------- -func paySecDoWithdraw() { - gs := griefSnap() +func payProbeWithdrawAll(gs griefRuntime) { node := focNode() funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - if funds == nil || lockup == nil { - paySecMu.Lock() - paySec.State = paySecWithdrawn - paySecMu.Unlock() return } available := new(big.Int).Sub(funds, lockup) if available.Sign() <= 0 { - log.Printf("[payment-security] no available funds to withdraw (funds=%s lockup=%s)", funds, lockup) - paySecMu.Lock() - paySec.State = paySecWithdrawn - paySecMu.Unlock() return } @@ -437,85 +427,29 @@ func paySecDoWithdraw() { foc.EncodeAddress(focCfg.USDFCAddr), foc.EncodeBigInt(available), ) + ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-withdraw-all") - ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "payment-security-withdraw") - - assert.Sometimes(ok, "Full withdrawal after settlement succeeds", map[string]any{ + assert.Sometimes(ok, "Full withdrawal of available funds succeeds", map[string]any{ "funds": funds.String(), "lockup": lockup.String(), "available": available.String(), - "ok": ok, }) if !ok { - log.Printf("[payment-security] ANOMALY: withdrawal of available=%s FAILED (funds=%s lockup=%s) — possible locked funds", available, funds, lockup) + log.Printf("[foc-payment-security] ANOMALY: withdrawal of available=%s FAILED (funds=%s lockup=%s)", available, funds, lockup) } else { - log.Printf("[payment-security] withdrawn %s (funds=%s lockup=%s)", available, funds, lockup) + log.Printf("[foc-payment-security] withdrawn available=%s", available) } - paySecMu.Lock() - paySec.State = paySecWithdrawn - paySecMu.Unlock() -} - -// --------------------------------------------------------------------------- -// Phase 6: Refund + Unauthorized Deposit Test (Audit L04) -// --------------------------------------------------------------------------- - -func paySecDoRefund() { - gs := griefSnap() - node := focNode() - - // ---- Audit L04: unauthorized third-party deposit test ---- - // Secondary client (attacker) tries to deposit to PRIMARY client's account - primaryFundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - - smallAmount := big.NewInt(1000000000000000) // 0.001 USDFC - depositCalldata := foc.BuildCalldata(foc.SigDeposit, - foc.EncodeAddress(focCfg.USDFCAddr), - foc.EncodeAddress(focCfg.ClientEthAddr), // target: PRIMARY client - foc.EncodeBigInt(smallAmount), - ) - - depositOK := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, depositCalldata, "payment-security-unauth-deposit") - - primaryFundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - - if primaryFundsBefore != nil && primaryFundsAfter != nil { - inflated := primaryFundsAfter.Cmp(primaryFundsBefore) > 0 - // TRUE SAFETY INVARIANT: third party cannot inflate someone's funds - assert.Always(!inflated || !depositOK, "Third-party deposit cannot inflate target account", map[string]any{ - "primaryBefore": primaryFundsBefore.String(), - "primaryAfter": primaryFundsAfter.String(), - "depositOK": depositOK, - "attacker": "secondary_client", - }) - if inflated && depositOK { - log.Printf("[payment-security] CRITICAL: unauthorized deposit inflated primary funds: %s → %s", primaryFundsBefore, primaryFundsAfter) - } + // Re-deposit for future probes + if ok { + redeposit := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(gs.ClientEth), + foc.EncodeBigInt(available), + ) + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, redeposit, "foc-payment-security-redeposit") } - - // ---- Refund secondary client for next cycle ---- - refundAmount := big.NewInt(griefUSDFCDeposit) - refundCalldata := foc.BuildCalldata(foc.SigTransfer, - foc.EncodeAddress(gs.ClientEth), - foc.EncodeBigInt(refundAmount), - ) - foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, refundCalldata, "payment-security-refund") - - // Re-deposit into FilecoinPay - redeposit := foc.BuildCalldata(foc.SigDeposit, - foc.EncodeAddress(focCfg.USDFCAddr), - foc.EncodeAddress(gs.ClientEth), - foc.EncodeBigInt(refundAmount), - ) - foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, redeposit, "payment-security-redeposit") - - log.Printf("[payment-security] refund complete, advancing to next cycle") - - paySecMu.Lock() - paySec.State = paySecRefunded - paySecMu.Unlock() } // --------------------------------------------------------------------------- @@ -523,7 +457,10 @@ func paySecDoRefund() { // --------------------------------------------------------------------------- func logPaySecProgress() { - s := paySecSnap() - log.Printf("[payment-security] state=%s cycles=%d railID=%v", - s.State, s.Cycles, s.RailID) + payProbesMu.Lock() + count := payProbeCount + payProbesMu.Unlock() + if count > 0 { + log.Printf("[foc-payment-security] probes_run=%d", count) + } } diff --git a/workload/cmd/stress-engine/foc_piece_security.go b/workload/cmd/stress-engine/foc_piece_security.go index 7b1c59b1..209e7c6c 100644 --- a/workload/cmd/stress-engine/foc_piece_security.go +++ b/workload/cmd/stress-engine/foc_piece_security.go @@ -14,61 +14,51 @@ import ( ) // =========================================================================== -// Scenario 1: Piece Lifecycle Security +// FOC Piece Lifecycle Security // -// Tests the full piece add/delete/retrieve lifecycle with security edge cases. -// Each deck invocation advances one phase. The scenario cycles continuously: +// Tests the full piece add/delete/retrieve lifecycle, then runs an independent +// attack probe. The first 4 phases have real ordering dependencies: // -// Init → Added → Verified → DeleteScheduled → DeleteVerified → -// AttackPhase → Terminated → Cleanup → (back to Init) +// Init (upload+add) → Verified (retrieve+CID check) → +// Deleted (schedule delete + post-delete retrieve) → +// Checked (verify count + proving) → Attack → (back to Init) // -// Covers: -// - Piece add/delete accounting correctness -// - Retrieval integrity before and after deletion (curio#1039) -// - Proving continuity after deletion -// - Nonce replay attacks on addPieces (EIP-712) +// The attack phase picks one random probe per cycle: +// - Nonce replay on addPieces // - Cross-dataset piece injection // - Double piece deletion -// - Post-termination piece addition race +// - Nonexistent piece deletion +// - Post-termination piece addition // -// Requires griefRuntime to be in griefReady state (secondary client set up). +// Requires griefRuntime in griefReady state with LastOnChainDSID > 0. // =========================================================================== // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- -type pieceSecState int +type pieceSecPhase int const ( - pieceSecInit pieceSecState = iota // upload piece to Curio - pieceSecAdded // piece added on-chain, snapshot counts - pieceSecVerified // retrieval integrity verified - pieceSecDeleteScheduled // deletion scheduled, re-retrieve check - pieceSecDeleteVerified // piece count decreased, proving OK - pieceSecAttackPhase // random attack probe - pieceSecTerminated // terminate service, try post-term add - pieceSecCleanup // delete dataset, re-create, reset + pieceSecInit pieceSecPhase = iota // upload + add piece + pieceSecVerify // retrieve + CID integrity check + pieceSecDelete // schedule deletion + post-delete retrieval (curio#1039) + pieceSecCheck // verify count decreased + proving continues + pieceSecAttack // random attack probe, then reset to Init ) -func (s pieceSecState) String() string { +func (s pieceSecPhase) String() string { switch s { case pieceSecInit: return "Init" - case pieceSecAdded: - return "Added" - case pieceSecVerified: - return "Verified" - case pieceSecDeleteScheduled: - return "DeleteScheduled" - case pieceSecDeleteVerified: - return "DeleteVerified" - case pieceSecAttackPhase: - return "AttackPhase" - case pieceSecTerminated: - return "Terminated" - case pieceSecCleanup: - return "Cleanup" + case pieceSecVerify: + return "Verify" + case pieceSecDelete: + return "Delete" + case pieceSecCheck: + return "Check" + case pieceSecAttack: + return "Attack" default: return "Unknown" } @@ -80,19 +70,16 @@ var ( ) type pieceSecRuntime struct { - State pieceSecState + Phase pieceSecPhase // Piece under test PieceCID string PieceID int - Nonce *big.Int // nonce used for the addPieces EIP-712 signature + Nonce *big.Int // nonce used for addPieces (for replay test) - // Snapshots for before/after comparison - CountBefore *big.Int - CountAfter *big.Int - ProvenBefore uint64 - TermDataSetID int // dataset ID being terminated (for cleanup) - TermClientDSID *big.Int // clientDataSetId for the terminated dataset + // Snapshots + CountBefore *big.Int + ProvenBefore uint64 // Progress Cycles int @@ -116,38 +103,26 @@ func DoFOCPieceSecurityProbe() { if _, ok := requireReady(); !ok { return } - - // Wait for griefing secondary client to be ready gs := griefSnap() - if gs.State != griefReady || gs.ClientKey == nil { + if gs.State != griefReady || gs.ClientKey == nil || gs.LastOnChainDSID == 0 { return } - if gs.LastOnChainDSID == 0 { - return // need a griefing dataset to operate on - } pieceSecMu.Lock() - state := pieceSec.State + phase := pieceSec.Phase pieceSecMu.Unlock() - switch state { + switch phase { case pieceSecInit: - pieceSecDoInit() - case pieceSecAdded: + pieceSecDoInit(gs) + case pieceSecVerify: pieceSecDoVerify() - case pieceSecVerified: - pieceSecDoScheduleDelete() - case pieceSecDeleteScheduled: - pieceSecDoVerifyDelete() - case pieceSecDeleteVerified: - pieceSecDoAttack() - case pieceSecAttackPhase: - pieceSecDoTerminate() - case pieceSecTerminated: - pieceSecDoCleanup() - case pieceSecCleanup: - // Cleanup resets to Init internally - pieceSecDoCleanup() + case pieceSecDelete: + pieceSecDoDelete(gs) + case pieceSecCheck: + pieceSecDoCheck(gs) + case pieceSecAttack: + pieceSecDoAttack(gs) } } @@ -155,11 +130,10 @@ func DoFOCPieceSecurityProbe() { // Phase 1: Upload + Add Piece // --------------------------------------------------------------------------- -func pieceSecDoInit() { +func pieceSecDoInit(gs griefRuntime) { if !foc.PingCurio(ctx) { return } - gs := griefSnap() node := focNode() // Upload a small random piece @@ -171,54 +145,49 @@ func pieceSecDoInit() { pieceCID, err := foc.CalculatePieceCID(data) if err != nil { - log.Printf("[piece-security] CalculatePieceCID failed: %v", err) + log.Printf("[foc-piece-security] CalculatePieceCID failed: %v", err) return } - if err := foc.UploadPiece(ctx, data, pieceCID); err != nil { - log.Printf("[piece-security] UploadPiece failed: %v", err) + log.Printf("[foc-piece-security] UploadPiece failed: %v", err) return } - if err := foc.WaitForPiece(ctx, pieceCID); err != nil { - log.Printf("[piece-security] WaitForPiece failed: %v", err) + log.Printf("[foc-piece-security] WaitForPiece failed: %v", err) return } - // Snapshot active piece count BEFORE + // Snapshot count BEFORE dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) countBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) // Add piece to griefing dataset nonce := new(big.Int).SetUint64(random.GetRandom()) - parsedCID, err := cid.Decode(pieceCID) if err != nil { - log.Printf("[piece-security] CID decode failed: %v", err) return } - cidBytes := parsedCID.Bytes() sig, err := foc.SignEIP712AddPieces( gs.ClientKey, focCfg.FWSSAddr, gs.LastClientDSID, nonce, - [][]byte{cidBytes}, nil, nil, + [][]byte{parsedCID.Bytes()}, nil, nil, ) if err != nil { - log.Printf("[piece-security] EIP-712 signing failed: %v", err) + log.Printf("[foc-piece-security] EIP-712 signing failed: %v", err) return } extraData := encodeAddPiecesExtraData(nonce, 1, sig) txHash, err := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{pieceCID}, hex.EncodeToString(extraData)) if err != nil { - log.Printf("[piece-security] AddPiecesHTTP failed: %v", err) + log.Printf("[foc-piece-security] AddPiecesHTTP failed: %v", err) return } pieceIDs, err := foc.WaitForPieceAddition(ctx, gs.LastOnChainDSID, txHash) if err != nil { - log.Printf("[piece-security] WaitForPieceAddition failed: %v", err) + log.Printf("[foc-piece-security] WaitForPieceAddition failed: %v", err) return } @@ -227,28 +196,23 @@ func pieceSecDoInit() { pieceID = pieceIDs[0] } - // Snapshot count AFTER + // Check count increased countAfter, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) - if countBefore != nil && countAfter != nil { - increased := countAfter.Cmp(countBefore) > 0 - assert.Sometimes(increased, "Active piece count increases after addition", map[string]any{ + assert.Sometimes(countAfter.Cmp(countBefore) > 0, "Active piece count increases after addition", map[string]any{ "countBefore": countBefore.String(), "countAfter": countAfter.String(), - "pieceCID": pieceCID, }) } - log.Printf("[piece-security] piece added: cid=%s pieceID=%d countBefore=%v countAfter=%v", - pieceCID, pieceID, countBefore, countAfter) + log.Printf("[foc-piece-security] piece added: cid=%s pieceID=%d", pieceCID, pieceID) pieceSecMu.Lock() pieceSec.PieceCID = pieceCID pieceSec.PieceID = pieceID pieceSec.Nonce = nonce - pieceSec.CountBefore = countBefore - pieceSec.CountAfter = countAfter - pieceSec.State = pieceSecAdded + pieceSec.CountBefore = countAfter // use post-add count as baseline for delete check + pieceSec.Phase = pieceSecVerify pieceSecMu.Unlock() } @@ -261,13 +225,12 @@ func pieceSecDoVerify() { data, err := foc.DownloadPiece(ctx, s.PieceCID) if err != nil { - log.Printf("[piece-security] download failed for %s: %v", s.PieceCID, err) - return // will retry next invocation + log.Printf("[foc-piece-security] download failed for %s: %v", s.PieceCID, err) + return } computedCID, err := foc.CalculatePieceCID(data) if err != nil { - log.Printf("[piece-security] CalculatePieceCID failed: %v", err) return } @@ -275,34 +238,29 @@ func pieceSecDoVerify() { assert.Sometimes(match, "Retrieved piece matches uploaded CID", map[string]any{ "pieceCID": s.PieceCID, "computedCID": computedCID, - "dataLen": len(data), }) - if !match { - log.Printf("[piece-security] INTEGRITY MISMATCH: expected=%s computed=%s", s.PieceCID, computedCID) - } else { - log.Printf("[piece-security] integrity verified: cid=%s", s.PieceCID) - } + log.Printf("[foc-piece-security] retrieval verified: cid=%s match=%v", s.PieceCID, match) pieceSecMu.Lock() - pieceSec.State = pieceSecVerified + pieceSec.Phase = pieceSecDelete pieceSecMu.Unlock() } // --------------------------------------------------------------------------- -// Phase 3: Schedule Deletion + Re-Retrieve (curio#1039) +// Phase 3: Schedule Deletion + Post-Delete Retrieval (curio#1039) // --------------------------------------------------------------------------- -func pieceSecDoScheduleDelete() { +func pieceSecDoDelete(gs griefRuntime) { s := pieceSecSnap() - gs := griefSnap() node := focNode() if s.PieceID == 0 { - // Can't delete without a valid piece ID — skip to attack phase - log.Printf("[piece-security] skipping delete (pieceID=0), advancing to attack phase") + // Curio didn't return piece IDs — can't delete by ID. + // Skip to attack phase (this IS the known gap). + log.Printf("[foc-piece-security] pieceID=0, skipping delete (Curio didn't return IDs)") pieceSecMu.Lock() - pieceSec.State = pieceSecDeleteVerified + pieceSec.Phase = pieceSecAttack pieceSecMu.Unlock() return } @@ -314,19 +272,18 @@ func pieceSecDoScheduleDelete() { } } - // Snapshot proven epoch before deletion + // Snapshot proven epoch before dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) provenBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetLastProvenEpoch, dsIDBytes)) - countBefore, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) - // Schedule piece deletion + // Schedule deletion pieceIDBig := big.NewInt(int64(s.PieceID)) sig, err := foc.SignEIP712SchedulePieceRemovals( gs.ClientKey, focCfg.FWSSAddr, gs.LastClientDSID, []*big.Int{pieceIDBig}, ) if err != nil { - log.Printf("[piece-security] EIP-712 deletion signing failed: %v", err) + log.Printf("[foc-piece-security] deletion signing failed: %v", err) return } @@ -340,36 +297,25 @@ func pieceSecDoScheduleDelete() { extraData, ) - ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-delete") + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "foc-piece-security-delete") if !ok { - log.Printf("[piece-security] schedulePieceDeletions failed, will retry") + log.Printf("[foc-piece-security] deletion tx failed, will retry") return } - log.Printf("[piece-security] deletion scheduled for pieceID=%d", s.PieceID) - - // curio#1039: immediately try to retrieve the piece after deletion scheduled - // The "no byte-level deletion" behavior means data should still be on disk + // curio#1039: immediately retrieve after deletion scheduled retrieveData, retrieveErr := foc.DownloadPiece(ctx, s.PieceCID) if retrieveErr != nil { - log.Printf("[piece-security] post-delete retrieval: %v (expected if deletion processed)", retrieveErr) + log.Printf("[foc-piece-security] post-delete retrieval: %v", retrieveErr) } else { - // Retrieval succeeded — verify it's not corrupt computedCID, cidErr := foc.CalculatePieceCID(retrieveData) - if cidErr != nil { - log.Printf("[piece-security] CRITICAL: post-delete retrieval returned data but CID computation failed: %v", cidErr) - } else { + if cidErr == nil { clean := computedCID == s.PieceCID - assert.Sometimes(clean, "Piece still retrievable after deletion scheduled", map[string]any{ - "pieceCID": s.PieceCID, - "computedCID": computedCID, - "dataLen": len(retrieveData), + assert.Sometimes(clean, "Piece retrievable after deletion scheduled", map[string]any{ + "pieceCID": s.PieceCID, + "clean": clean, }) - if !clean { - log.Printf("[piece-security] CRITICAL: post-delete data CORRUPTED: expected=%s got=%s", s.PieceCID, computedCID) - } else { - log.Printf("[piece-security] post-delete retrieval OK (no byte-level deletion confirmed)") - } + log.Printf("[foc-piece-security] post-delete retrieval: clean=%v", clean) } } @@ -380,60 +326,47 @@ func pieceSecDoScheduleDelete() { pieceSecMu.Lock() pieceSec.ProvenBefore = provenBeforeU64 - pieceSec.CountBefore = countBefore - pieceSec.State = pieceSecDeleteScheduled + pieceSec.Phase = pieceSecCheck pieceSecMu.Unlock() } // --------------------------------------------------------------------------- -// Phase 4: Verify Delete — piece count decreased, proving continues +// Phase 4: Verify Count Decreased + Proving Continues // --------------------------------------------------------------------------- -func pieceSecDoVerifyDelete() { +func pieceSecDoCheck(gs griefRuntime) { s := pieceSecSnap() - gs := griefSnap() node := focNode() dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) - countAfter, err := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) - if err != nil { - log.Printf("[piece-security] getActivePieceCount failed: %v", err) - return - } - + countAfter, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) if s.CountBefore != nil && countAfter != nil { decreased := countAfter.Cmp(s.CountBefore) < 0 assert.Sometimes(decreased, "Active piece count decreases after deletion", map[string]any{ "countBefore": s.CountBefore.String(), "countAfter": countAfter.String(), - "pieceID": s.PieceID, }) - log.Printf("[piece-security] delete verified: countBefore=%s countAfter=%s", s.CountBefore, countAfter) } - // Check proving still advances provenAfter, _ := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigGetLastProvenEpoch, dsIDBytes)) if provenAfter != nil { - advanced := provenAfter.Uint64() >= s.ProvenBefore - assert.Sometimes(advanced, "Proving continues after piece deletion", map[string]any{ + assert.Sometimes(provenAfter.Uint64() >= s.ProvenBefore, "Proving continues after piece deletion", map[string]any{ "provenBefore": s.ProvenBefore, "provenAfter": provenAfter.Uint64(), }) } pieceSecMu.Lock() - pieceSec.CountAfter = countAfter - pieceSec.State = pieceSecDeleteVerified + pieceSec.Phase = pieceSecAttack pieceSecMu.Unlock() } // --------------------------------------------------------------------------- -// Phase 5: Attack Phase — randomly pick one attack per cycle +// Phase 5: Random Attack Probe, then reset // --------------------------------------------------------------------------- -func pieceSecDoAttack() { - gs := griefSnap() +func pieceSecDoAttack(gs griefRuntime) { s := pieceSecSnap() type attack struct { @@ -445,25 +378,39 @@ func pieceSecDoAttack() { {"CrossDatasetInject", attackCrossDataset}, {"DoubleDeletion", attackDoubleDeletion}, {"NonexistentDelete", attackNonexistentDelete}, + {"PostTerminationAdd", attackPostTerminationAdd}, } pick := attacks[rngIntn(len(attacks))] - log.Printf("[piece-security] attack: %s", pick.name) + log.Printf("[foc-piece-security] attack: %s", pick.name) pick.fn(gs, s) + // Cycle complete — reset pieceSecMu.Lock() pieceSec.AttacksDone++ - pieceSec.State = pieceSecAttackPhase + pieceSec.Cycles++ + cycles := pieceSec.Cycles + pieceSec.Phase = pieceSecInit + pieceSec.PieceCID = "" + pieceSec.PieceID = 0 + pieceSec.Nonce = nil + pieceSec.CountBefore = nil + pieceSec.ProvenBefore = 0 pieceSecMu.Unlock() + + log.Printf("[foc-piece-security] cycle %d complete", cycles) + assert.Sometimes(true, "Piece security cycle completes", map[string]any{"cycles": cycles}) } -// attackNonceReplay reuses the nonce from the previous addPieces call. +// --------------------------------------------------------------------------- +// Attack: Nonce Replay on addPieces +// --------------------------------------------------------------------------- + func attackNonceReplay(gs griefRuntime, s pieceSecRuntime) { if s.Nonce == nil || !foc.PingCurio(ctx) { return } - // Upload a new piece data := make([]byte, 128) for i := range data { data[i] = byte(random.GetRandom() & 0xFF) @@ -472,9 +419,7 @@ func attackNonceReplay(gs griefRuntime, s pieceSecRuntime) { if err != nil { return } - if err := foc.UploadPiece(ctx, data, newCID); err != nil { - return - } + _ = foc.UploadPiece(ctx, data, newCID) _ = foc.WaitForPiece(ctx, newCID) parsedCID, err := cid.Decode(newCID) @@ -485,7 +430,7 @@ func attackNonceReplay(gs griefRuntime, s pieceSecRuntime) { // Sign with the SAME nonce as the previous add sig, err := foc.SignEIP712AddPieces( gs.ClientKey, focCfg.FWSSAddr, - gs.LastClientDSID, s.Nonce, // replayed nonce + gs.LastClientDSID, s.Nonce, [][]byte{parsedCID.Bytes()}, nil, nil, ) if err != nil { @@ -496,33 +441,25 @@ func attackNonceReplay(gs griefRuntime, s pieceSecRuntime) { _, httpErr := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{newCID}, hex.EncodeToString(extraData)) if httpErr != nil { - log.Printf("[piece-security] nonce replay rejected at HTTP: %v", httpErr) - assert.Sometimes(true, "AddPieces nonce replay rejected", map[string]any{ - "replayedNonce": s.Nonce.String(), - }) + assert.Sometimes(true, "AddPieces nonce replay rejected", map[string]any{"nonce": s.Nonce.String()}) } else { - // HTTP accepted — check if it actually lands on-chain - log.Printf("[piece-security] CRITICAL: nonce replay accepted by Curio HTTP — checking on-chain") - // Even if HTTP accepted, the contract should reject it - assert.Sometimes(false, "AddPieces nonce replay rejected", map[string]any{ - "replayedNonce": s.Nonce.String(), - "note": "HTTP accepted replayed nonce — contract may still reject", - }) + log.Printf("[foc-piece-security] CRITICAL: nonce replay accepted by Curio HTTP") + assert.Sometimes(false, "AddPieces nonce replay rejected", map[string]any{"nonce": s.Nonce.String()}) } } -// attackCrossDataset signs addPieces for the griefing dataset but submits to the primary FOC dataset. +// --------------------------------------------------------------------------- +// Attack: Cross-Dataset Piece Injection +// --------------------------------------------------------------------------- + func attackCrossDataset(gs griefRuntime, _ pieceSecRuntime) { if !foc.PingCurio(ctx) { return } focS := snap() - if focS.OnChainDataSetID == 0 || gs.LastOnChainDSID == 0 { + if focS.OnChainDataSetID == 0 || gs.LastOnChainDSID == 0 || focS.OnChainDataSetID == gs.LastOnChainDSID { return } - if focS.OnChainDataSetID == gs.LastOnChainDSID { - return // same dataset, not a meaningful test - } data := make([]byte, 128) for i := range data { @@ -532,9 +469,7 @@ func attackCrossDataset(gs griefRuntime, _ pieceSecRuntime) { if err != nil { return } - if err := foc.UploadPiece(ctx, data, newCID); err != nil { - return - } + _ = foc.UploadPiece(ctx, data, newCID) _ = foc.WaitForPiece(ctx, newCID) parsedCID, err := cid.Decode(newCID) @@ -543,10 +478,10 @@ func attackCrossDataset(gs griefRuntime, _ pieceSecRuntime) { } nonce := new(big.Int).SetUint64(random.GetRandom()) - // Sign for GRIEFING dataset + // Sign for GRIEFING dataset, submit to PRIMARY dataset sig, err := foc.SignEIP712AddPieces( gs.ClientKey, focCfg.FWSSAddr, - gs.LastClientDSID, nonce, // griefing clientDataSetId + gs.LastClientDSID, nonce, [][]byte{parsedCID.Bytes()}, nil, nil, ) if err != nil { @@ -554,26 +489,24 @@ func attackCrossDataset(gs griefRuntime, _ pieceSecRuntime) { } extraData := encodeAddPiecesExtraData(nonce, 1, sig) - - // Submit to PRIMARY FOC dataset — signature mismatch _, httpErr := foc.AddPiecesHTTP(ctx, focS.OnChainDataSetID, []string{newCID}, hex.EncodeToString(extraData)) if httpErr != nil { - log.Printf("[piece-security] cross-dataset injection rejected: %v", httpErr) assert.Sometimes(true, "Cross-dataset piece injection rejected", map[string]any{ - "signedFor": gs.LastOnChainDSID, - "submittedTo": focS.OnChainDataSetID, + "signedFor": gs.LastOnChainDSID, "submittedTo": focS.OnChainDataSetID, }) } else { - log.Printf("[piece-security] CRITICAL: cross-dataset injection accepted by HTTP") + log.Printf("[foc-piece-security] CRITICAL: cross-dataset injection accepted") assert.Sometimes(false, "Cross-dataset piece injection rejected", map[string]any{ - "signedFor": gs.LastOnChainDSID, - "submittedTo": focS.OnChainDataSetID, + "signedFor": gs.LastOnChainDSID, "submittedTo": focS.OnChainDataSetID, }) } } -// attackDoubleDeletion tries to delete the same pieceID that was already deleted in phase 3. +// --------------------------------------------------------------------------- +// Attack: Double Piece Deletion +// --------------------------------------------------------------------------- + func attackDoubleDeletion(gs griefRuntime, s pieceSecRuntime) { if s.PieceID == 0 || focCfg.SPKey == nil { return @@ -599,23 +532,20 @@ func attackDoubleDeletion(gs griefRuntime, s pieceSecRuntime) { extraData, ) - ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-double-del") + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "foc-piece-security-double-del") if !ok { - log.Printf("[piece-security] double deletion correctly rejected for pieceID=%d", s.PieceID) - assert.Sometimes(true, "Double piece deletion rejected", map[string]any{ - "pieceID": s.PieceID, - }) + assert.Sometimes(true, "Double piece deletion rejected", map[string]any{"pieceID": s.PieceID}) } else { - log.Printf("[piece-security] CRITICAL: double deletion SUCCEEDED for pieceID=%d", s.PieceID) - assert.Sometimes(false, "Double piece deletion rejected", map[string]any{ - "pieceID": s.PieceID, - "note": "same piece deleted twice — accounting bug", - }) + log.Printf("[foc-piece-security] CRITICAL: double deletion succeeded for pieceID=%d", s.PieceID) + assert.Sometimes(false, "Double piece deletion rejected", map[string]any{"pieceID": s.PieceID}) } } -// attackNonexistentDelete tries to delete a piece ID that doesn't exist. +// --------------------------------------------------------------------------- +// Attack: Nonexistent Piece Deletion +// --------------------------------------------------------------------------- + func attackNonexistentDelete(gs griefRuntime, _ pieceSecRuntime) { if focCfg.SPKey == nil { return @@ -641,81 +571,52 @@ func attackNonexistentDelete(gs griefRuntime, _ pieceSecRuntime) { extraData, ) - ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-fake-del") + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "foc-piece-security-fake-del") if !ok { - log.Printf("[piece-security] nonexistent piece deletion correctly rejected (fakeID=%s)", fakePieceID) - assert.Sometimes(true, "Nonexistent piece deletion rejected", map[string]any{ - "fakePieceID": fakePieceID.String(), - }) + assert.Sometimes(true, "Nonexistent piece deletion rejected", map[string]any{"fakeID": fakePieceID.String()}) } else { - log.Printf("[piece-security] CRITICAL: nonexistent piece deletion SUCCEEDED (fakeID=%s)", fakePieceID) - assert.Sometimes(false, "Nonexistent piece deletion rejected", map[string]any{ - "fakePieceID": fakePieceID.String(), - }) + log.Printf("[foc-piece-security] CRITICAL: nonexistent piece deletion succeeded (fakeID=%s)", fakePieceID) + assert.Sometimes(false, "Nonexistent piece deletion rejected", map[string]any{"fakeID": fakePieceID.String()}) } } // --------------------------------------------------------------------------- -// Phase 6: Post-Termination Piece Addition Race +// Attack: Post-Termination Piece Addition // --------------------------------------------------------------------------- -func pieceSecDoTerminate() { - gs := griefSnap() - node := focNode() - - if focCfg.SPKey == nil { - focCfg.ReloadSPKey() - if focCfg.SPKey == nil { - return - } - } - if !foc.PingCurio(ctx) { +func attackPostTerminationAdd(gs griefRuntime, _ pieceSecRuntime) { + if focCfg.SPKey == nil || !foc.PingCurio(ctx) { return } + node := focNode() - // Terminate the griefing dataset + // Terminate the griefing dataset's service calldata := foc.BuildCalldata(foc.SigTerminateService, foc.EncodeBigInt(gs.LastClientDSID), ) - - ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.FWSSAddr, calldata, "piece-security-terminate") + ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.FWSSAddr, calldata, "foc-piece-security-terminate") if !ok { - log.Printf("[piece-security] terminateService failed, will retry") + log.Printf("[foc-piece-security] terminateService failed") return } - log.Printf("[piece-security] service terminated for dataset=%d", gs.LastOnChainDSID) - - // THE KEY TEST: immediately try to add a piece after termination + // Immediately try to add a piece — should be rejected data := make([]byte, 128) for i := range data { data[i] = byte(random.GetRandom() & 0xFF) } newCID, err := foc.CalculatePieceCID(data) if err != nil { - log.Printf("[piece-security] post-term CID calc failed: %v", err) - pieceSecMu.Lock() - pieceSec.TermDataSetID = gs.LastOnChainDSID - pieceSec.TermClientDSID = gs.LastClientDSID - pieceSec.State = pieceSecTerminated - pieceSecMu.Unlock() return } - _ = foc.UploadPiece(ctx, data, newCID) _ = foc.WaitForPiece(ctx, newCID) parsedCID, err := cid.Decode(newCID) if err != nil { - pieceSecMu.Lock() - pieceSec.TermDataSetID = gs.LastOnChainDSID - pieceSec.TermClientDSID = gs.LastClientDSID - pieceSec.State = pieceSecTerminated - pieceSecMu.Unlock() return } - nonce := new(big.Int).SetUint64(random.GetRandom()) sig, err := foc.SignEIP712AddPieces( gs.ClientKey, focCfg.FWSSAddr, @@ -723,11 +624,6 @@ func pieceSecDoTerminate() { [][]byte{parsedCID.Bytes()}, nil, nil, ) if err != nil { - pieceSecMu.Lock() - pieceSec.TermDataSetID = gs.LastOnChainDSID - pieceSec.TermClientDSID = gs.LastClientDSID - pieceSec.State = pieceSecTerminated - pieceSecMu.Unlock() return } @@ -735,90 +631,12 @@ func pieceSecDoTerminate() { _, httpErr := foc.AddPiecesHTTP(ctx, gs.LastOnChainDSID, []string{newCID}, hex.EncodeToString(extraData)) if httpErr != nil { - log.Printf("[piece-security] post-termination add rejected: %v", httpErr) - assert.Sometimes(true, "Piece addition blocked after termination", map[string]any{ - "dataSetID": gs.LastOnChainDSID, - }) + assert.Sometimes(true, "Piece addition blocked after termination", map[string]any{"dsID": gs.LastOnChainDSID}) + log.Printf("[foc-piece-security] post-termination add correctly rejected") } else { - log.Printf("[piece-security] CRITICAL: post-termination add ACCEPTED for dataset=%d", gs.LastOnChainDSID) - assert.Sometimes(false, "Piece addition blocked after termination", map[string]any{ - "dataSetID": gs.LastOnChainDSID, - "note": "pieces added to dying dataset — orphan risk", - }) + log.Printf("[foc-piece-security] CRITICAL: post-termination add ACCEPTED for dataset=%d", gs.LastOnChainDSID) + assert.Sometimes(false, "Piece addition blocked after termination", map[string]any{"dsID": gs.LastOnChainDSID}) } - - pieceSecMu.Lock() - pieceSec.TermDataSetID = gs.LastOnChainDSID - pieceSec.TermClientDSID = gs.LastClientDSID - pieceSec.State = pieceSecTerminated - pieceSecMu.Unlock() -} - -// --------------------------------------------------------------------------- -// Phase 7: Cleanup — delete dataset, re-create, reset -// --------------------------------------------------------------------------- - -func pieceSecDoCleanup() { - s := pieceSecSnap() - gs := griefSnap() - node := focNode() - - if focCfg.SPKey == nil { - return - } - - // Delete the terminated dataset - if s.TermDataSetID > 0 && s.TermClientDSID != nil { - sig, err := foc.SignEIP712DeleteDataSet(gs.ClientKey, focCfg.FWSSAddr, s.TermClientDSID) - if err != nil { - log.Printf("[piece-security] deleteDataSet EIP-712 signing failed: %v", err) - // Don't block — reset anyway - } else { - extraData := encodeBytes(sig) - calldata := foc.BuildCalldata(foc.SigDeleteDataSet, - foc.EncodeBigInt(big.NewInt(int64(s.TermDataSetID))), - foc.EncodeBigInt(big.NewInt(64)), - extraData, - ) - sent := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "piece-security-cleanup") - if sent { - log.Printf("[piece-security] dataset %d deleted", s.TermDataSetID) - } else { - log.Printf("[piece-security] dataset %d delete failed (endEpoch may not have passed yet), will retry", s.TermDataSetID) - return // retry next invocation - } - } - } - - // Re-create a new dataset for the griefing runtime via probeEmptyDatasetFee flow - // This is handled by the griefing probe on its next invocation once it detects - // the dataset was deleted. We just need to reset griefRT state. - griefMu.Lock() - griefRT.LastOnChainDSID = 0 - griefRT.LastClientDSID = nil - griefRT.DSCreated = 0 - griefMu.Unlock() - - pieceSecMu.Lock() - pieceSec.Cycles++ - cycles := pieceSec.Cycles - attacks := pieceSec.AttacksDone - pieceSec.State = pieceSecInit - pieceSec.PieceCID = "" - pieceSec.PieceID = 0 - pieceSec.Nonce = nil - pieceSec.CountBefore = nil - pieceSec.CountAfter = nil - pieceSec.ProvenBefore = 0 - pieceSec.TermDataSetID = 0 - pieceSec.TermClientDSID = nil - pieceSecMu.Unlock() - - log.Printf("[piece-security] cycle %d complete (attacks=%d), resetting to Init", cycles, attacks) - assert.Sometimes(true, "Piece security scenario cycle completes", map[string]any{ - "cycles": cycles, - "attacks": attacks, - }) } // --------------------------------------------------------------------------- @@ -827,6 +645,7 @@ func pieceSecDoCleanup() { func logPieceSecProgress() { s := pieceSecSnap() - log.Printf("[piece-security] state=%s cycles=%d attacks=%d pieceCID=%s pieceID=%d", - s.State, s.Cycles, s.AttacksDone, s.PieceCID, s.PieceID) + if s.Cycles > 0 || s.Phase != pieceSecInit { + log.Printf("[foc-piece-security] phase=%s cycles=%d attacks=%d", s.Phase, s.Cycles, s.AttacksDone) + } } diff --git a/workload/cmd/stress-engine/foc_resilience.go b/workload/cmd/stress-engine/foc_resilience.go index 25fd51fd..a0bc34e9 100644 --- a/workload/cmd/stress-engine/foc_resilience.go +++ b/workload/cmd/stress-engine/foc_resilience.go @@ -126,7 +126,7 @@ func resDoHTTPStress() { base := foc.CurioBaseURL() client := &http.Client{Timeout: 30 * time.Second} - log.Printf("[resilience] starting HTTP stress barrage") + log.Printf("[foc-resilience] starting HTTP stress barrage") type malformedReq struct { name string @@ -163,13 +163,13 @@ func resDoHTTPStress() { resp, err := client.Do(req) if err != nil { - log.Printf("[resilience] %s: connection error (may be fine): %v", r.name, err) + log.Printf("[foc-resilience] %s: connection error (may be fine): %v", r.name, err) continue } io.Copy(io.Discard, resp.Body) resp.Body.Close() - log.Printf("[resilience] %s: status=%d", r.name, resp.StatusCode) + log.Printf("[foc-resilience] %s: status=%d", r.name, resp.StatusCode) accepted++ } @@ -181,7 +181,7 @@ func resDoHTTPStress() { }) if !pingOK { - log.Printf("[resilience] CRITICAL: Curio not reachable after HTTP stress barrage!") + log.Printf("[foc-resilience] CRITICAL: Curio not reachable after HTTP stress barrage!") return } @@ -193,7 +193,7 @@ func resDoHTTPStress() { resSec.HTTPBarrages++ resSecMu.Unlock() - log.Printf("[resilience] HTTP barrage complete, Curio alive. Creating orphan dataset...") + log.Printf("[foc-resilience] HTTP barrage complete, Curio alive. Creating orphan dataset...") // Now create an empty dataset (orphan rail test) if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { @@ -216,7 +216,7 @@ func resDoHTTPStress() { metadataKeys, metadataValues, ) if err != nil { - log.Printf("[resilience] EIP-712 signing failed: %v", err) + log.Printf("[foc-resilience] EIP-712 signing failed: %v", err) return } @@ -225,17 +225,17 @@ func resDoHTTPStress() { txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) if err != nil { - log.Printf("[resilience] orphan dataset creation failed: %v", err) + log.Printf("[foc-resilience] orphan dataset creation failed: %v", err) return } onChainID, err := foc.WaitForDataSetCreation(ctx, txHash) if err != nil { - log.Printf("[resilience] orphan dataset confirmation failed: %v", err) + log.Printf("[foc-resilience] orphan dataset confirmation failed: %v", err) return } - log.Printf("[resilience] orphan dataset created: onChainID=%d (no pieces will be added)", onChainID) + log.Printf("[foc-resilience] orphan dataset created: onChainID=%d (no pieces will be added)", onChainID) resSecMu.Lock() resSec.OrphanDSID = onChainID @@ -270,7 +270,7 @@ func resDoOrphanCheck() { // Read current funds fundsNow := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, gs.ClientEth) - log.Printf("[resilience] orphan dataset %d: live=%v activePieces=%v funds=%v (before=%v)", + log.Printf("[foc-resilience] orphan dataset %d: live=%v activePieces=%v funds=%v (before=%v)", s.OrphanDSID, live, activeCount, fundsNow, s.OrphanFundsBefore) // Check: with zero pieces, client should not be losing funds to storage charges @@ -305,7 +305,7 @@ func resDoOrphanCleanup() { node := focNode() dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(s.OrphanDSID))) live, _ := foc.EthCallBool(ctx, node, focCfg.PDPAddr, foc.BuildCalldata(foc.SigDataSetLive, dsIDBytes)) - log.Printf("[resilience] orphan dataset %d live=%v (left for sidecar monitoring)", s.OrphanDSID, live) + log.Printf("[foc-resilience] orphan dataset %d live=%v (left for sidecar monitoring)", s.OrphanDSID, live) } resSecMu.Lock() @@ -317,7 +317,7 @@ func resDoOrphanCleanup() { resSec.OrphanFundsBefore = nil resSecMu.Unlock() - log.Printf("[resilience] cycle %d complete (HTTP barrages=%d)", cycles, barrages) + log.Printf("[foc-resilience] cycle %d complete (HTTP barrages=%d)", cycles, barrages) assert.Sometimes(true, "Resilience scenario cycle completes", map[string]any{ "cycles": cycles, "httpBarrages": barrages, @@ -345,6 +345,6 @@ func hugeExtraDataPayload() []byte { func logResProgress() { s := resSnap() - log.Printf("[resilience] state=%s cycles=%d httpBarrages=%d orphanDSID=%d", + log.Printf("[foc-resilience] state=%s cycles=%d httpBarrages=%d orphanDSID=%d", s.State, s.Cycles, s.HTTPBarrages, s.OrphanDSID) } diff --git a/workload/cmd/stress-engine/foc_vectors.go b/workload/cmd/stress-engine/foc_vectors.go index e590b4ee..f9cd715a 100644 --- a/workload/cmd/stress-engine/foc_vectors.go +++ b/workload/cmd/stress-engine/foc_vectors.go @@ -105,13 +105,20 @@ func requireReady() (focRuntime, bool) { return s, s.State == focStateReady } -// returnPiece puts a piece back on the uploaded queue (used on failure paths). +// returnPiece puts a piece back on the uploaded queue (used on add failure paths). func returnPiece(p pieceRef) { focStateMu.Lock() focState.UploadedPieces = append(focState.UploadedPieces, p) focStateMu.Unlock() } +// returnDeletedPiece puts a piece back on the added queue (used on delete failure paths). +func returnDeletedPiece(p pieceRef) { + focStateMu.Lock() + focState.AddedPieces = append(focState.AddedPieces, p) + focStateMu.Unlock() +} + // focNode returns a lotus node (not forest) for FOC transactions. func focNode() api.FullNode { if n, ok := nodes["lotus0"]; ok { @@ -615,12 +622,18 @@ func DoFOCWithdraw() { node := focNode() funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) - if funds == nil || funds.Sign() == 0 { + lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, focCfg.ClientEthAddr) + if funds == nil || lockup == nil || funds.Sign() == 0 { + return + } + + available := new(big.Int).Sub(funds, lockup) + if available.Sign() <= 0 { return } pct := 1 + rngIntn(5) - amount := new(big.Int).Mul(funds, big.NewInt(int64(pct))) + amount := new(big.Int).Mul(available, big.NewInt(int64(pct))) amount.Div(amount, big.NewInt(100)) if amount.Sign() == 0 { return @@ -633,7 +646,7 @@ func DoFOCWithdraw() { ok := foc.SendEthTx(ctx, node, focCfg.ClientKey, focCfg.FilPayAddr, calldata, "foc-withdraw") - log.Printf("[foc-withdraw] amount=%s (of %s, %d%%) ok=%v", amount, funds, pct, ok) + log.Printf("[foc-withdraw] amount=%s (of available=%s, %d%%) ok=%v", amount, available, pct, ok) assert.Sometimes(ok, "USDFC withdrawal from FilecoinPay succeeds", map[string]any{ "amount": amount.String(), }) @@ -676,6 +689,7 @@ func DoFOCDeletePiece() { ) if err != nil { log.Printf("[foc-delete-piece] EIP-712 signing failed: %v", err) + returnDeletedPiece(piece) return } @@ -691,6 +705,12 @@ func DoFOCDeletePiece() { ok := foc.SendEthTx(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "foc-delete-piece") + if !ok { + log.Printf("[foc-delete-piece] tx failed, returning piece to state: pieceID=%d cid=%s", piece.PieceID, piece.PieceCID) + returnDeletedPiece(piece) + return + } + log.Printf("[foc-delete-piece] pieceID=%d cid=%s ok=%v", piece.PieceID, piece.PieceCID, ok) assert.Sometimes(ok, "piece deletion scheduled", map[string]any{ "pieceID": piece.PieceID, @@ -968,10 +988,3 @@ func padTo32(n int) int { return ((n + 31) / 32) * 32 } -func buildCreateDataSetCalldata(fwssAddr []byte, extraData []byte) []byte { - return foc.BuildCalldata(foc.SigCreateDataSet, - foc.EncodeAddress(fwssAddr), - foc.EncodeBigInt(big.NewInt(64)), - encodeBytes(extraData), - ) -} diff --git a/workload/cmd/stress-engine/griefing_vectors.go b/workload/cmd/stress-engine/griefing_vectors.go index 2153838b..8a6918ea 100644 --- a/workload/cmd/stress-engine/griefing_vectors.go +++ b/workload/cmd/stress-engine/griefing_vectors.go @@ -11,6 +11,7 @@ import ( "github.com/antithesishq/antithesis-sdk-go/assert" "github.com/antithesishq/antithesis-sdk-go/random" filbig "github.com/filecoin-project/go-state-types/big" + "github.com/filecoin-project/lotus/api" ) // =========================================================================== @@ -76,14 +77,9 @@ type griefRuntime struct { DSCreated int LastFunds *big.Int - // Extended state for additional probes + // Shared with piece-security and payment-security scenarios LastOnChainDSID int // most recent on-chain dataset ID LastClientDSID *big.Int // most recent clientDataSetId (for EIP-712) - TermPending bool // terminateService called, waiting for delete - DeletedCount int - SettledCount int - WithdrawCount int - UploadSessions int } func griefSnap() griefRuntime { @@ -140,7 +136,7 @@ func DoPDPGriefingProbe() { // doGriefInit picks the secondary client wallet and transfers USDFC from the primary client. func doGriefInit() { if len(addrs) < 2 { - log.Printf("[sybil-fee-grief] not enough wallets in keystore") + log.Printf("[foc-griefing] not enough wallets in keystore") return } @@ -153,33 +149,33 @@ func doGriefInit() { griefRT.ClientKey = ki.PrivateKey griefRT.ClientEth = foc.DeriveEthAddr(ki.PrivateKey) addrs = addrs[:len(addrs)-1] - log.Printf("[sybil-fee-grief] secondary client: filAddr=%s ethAddr=0x%x (removed from wallet pool)", addr, griefRT.ClientEth) + log.Printf("[foc-griefing] secondary client: filAddr=%s ethAddr=0x%x (removed from wallet pool)", addr, griefRT.ClientEth) } clientEth := griefRT.ClientEth griefMu.Unlock() if clientEth == nil { - log.Printf("[sybil-fee-grief] failed to derive secondary client ETH address") + log.Printf("[foc-griefing] failed to derive secondary client ETH address") return } node := focNode() - // Transfer 0.06 USDFC from primary client to secondary client + // Transfer 0.5 USDFC from primary client to secondary client amount := big.NewInt(griefUSDFCDeposit) calldata := foc.BuildCalldata(foc.SigTransfer, foc.EncodeAddress(clientEth), foc.EncodeBigInt(amount), ) - log.Printf("[sybil-fee-grief] state=Init → funding secondary client with USDFC") + log.Printf("[foc-griefing] state=Init → funding secondary client with USDFC") ok := foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, calldata, "pdp-acct-fund") if !ok { - log.Printf("[sybil-fee-grief] USDFC transfer failed, will retry") + log.Printf("[foc-griefing] USDFC transfer failed, will retry") return } - log.Printf("[sybil-fee-grief] secondary client funded") + log.Printf("[foc-griefing] secondary client funded") griefMu.Lock() griefRT.State = griefFunded @@ -195,18 +191,18 @@ func doGriefCreateActor() { s := griefSnap() node := focNode() - log.Printf("[pdp-accounting] state=Funded → creating f4 actor via EVM transfer") + log.Printf("[foc-griefing] state=Funded → creating f4 actor via EVM transfer") // Send 1 FIL from FOC client to secondary client's ETH address. // This creates the f4 actor and funds it for gas on subsequent EVM transactions. gasFund := filbig.NewInt(1_000_000_000_000_000_000) // 1 FIL ok := foc.SendEthTxConfirmedWithValue(ctx, node, focCfg.ClientKey, s.ClientEth, gasFund, "pdp-acct-f4") if !ok { - log.Printf("[pdp-accounting] f4 actor creation failed, will retry") + log.Printf("[foc-griefing] f4 actor creation failed, will retry") return } - log.Printf("[pdp-accounting] f4 actor created for ethAddr=0x%x", s.ClientEth) + log.Printf("[foc-griefing] f4 actor created for ethAddr=0x%x", s.ClientEth) griefMu.Lock() griefRT.State = griefActorCreated @@ -224,14 +220,14 @@ func doGriefApprove() { foc.EncodeBigInt(maxUint256), ) - log.Printf("[sybil-fee-grief] state=ActorCreated → approving FPV1") + log.Printf("[foc-griefing] state=ActorCreated → approving FPV1") ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.USDFCAddr, calldata, "pdp-acct-approve") if !ok { - log.Printf("[sybil-fee-grief] approve failed, will retry") + log.Printf("[foc-griefing] approve failed, will retry") return } - log.Printf("[sybil-fee-grief] FPV1 approved") + log.Printf("[foc-griefing] FPV1 approved") griefMu.Lock() griefRT.State = griefApproved @@ -250,15 +246,15 @@ func doGriefDeposit() { foc.EncodeBigInt(amount), ) - log.Printf("[sybil-fee-grief] state=Approved → depositing USDFC into FPV1") + log.Printf("[foc-griefing] state=Approved → depositing USDFC into FPV1") ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-acct-deposit") if !ok { - log.Printf("[sybil-fee-grief] deposit failed, will retry") + log.Printf("[foc-griefing] deposit failed, will retry") return } funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) - log.Printf("[sybil-fee-grief] FPV1 funds after deposit: %s", funds) + log.Printf("[foc-griefing] FPV1 funds after deposit: %s", funds) griefMu.Lock() griefRT.State = griefDeposited @@ -282,14 +278,14 @@ func doGriefApproveOperator() { foc.EncodeBigInt(maxLockupPeriod), ) - log.Printf("[sybil-fee-grief] state=Deposited → approving FWSS as operator") + log.Printf("[foc-griefing] state=Deposited → approving FWSS as operator") ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-acct-op") if !ok { - log.Printf("[sybil-fee-grief] operator approval failed, will retry") + log.Printf("[foc-griefing] operator approval failed, will retry") return } - log.Printf("[sybil-fee-grief] FWSS operator approved") + log.Printf("[foc-griefing] FWSS operator approved") griefMu.Lock() griefRT.State = griefOperatorOK @@ -303,7 +299,7 @@ func doGriefArm() { funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) - log.Printf("[sybil-fee-grief] state=OperatorApproved → ready. initialFunds=%s", funds) + log.Printf("[foc-griefing] state=OperatorApproved → ready. initialFunds=%s", funds) assert.Sometimes(true, "PDP secondary client setup completes", map[string]any{ "initialFunds": funds.String(), }) @@ -318,19 +314,54 @@ func doGriefArm() { // Steady State — Probe Dispatcher // --------------------------------------------------------------------------- +// griefCooldownEpochs controls how many epochs to wait between griefing +// dispatch rounds. After running one probe, the dispatcher checks chain +// height and skips until the cooldown has elapsed. This prevents the +// griefing probes from continuously draining funds and starving other +// scenarios. Default 200 epochs (~13 min at 4s blocks). +var griefCooldownEpochs = int64(envInt("GRIEF_COOLDOWN_EPOCHS", 200)) + +var griefLastDispatchEpoch int64 + func doGriefDispatch() { + node := focNode() + head, err := node.ChainHead(ctx) + if err != nil { + return + } + currentEpoch := int64(head.Height()) + + gs := griefSnap() + + // First dispatch: create a dataset to set LastOnChainDSID. + // This unblocks piece-security and payment-security scenarios. + // Only runs once — the sybil fee assertion only needs one observation. + if gs.LastOnChainDSID == 0 { + log.Printf("[foc-griefing] creating initial dataset (epoch=%d)", currentEpoch) + probeEmptyDatasetFee() + griefLastDispatchEpoch = currentEpoch + return + } + + // Enforce cooldown — skip if we dispatched recently + if griefLastDispatchEpoch > 0 && currentEpoch-griefLastDispatchEpoch < griefCooldownEpochs { + return + } + griefLastDispatchEpoch = currentEpoch + + // After the initial dataset is created, only run probes that don't + // drain funds. EmptyDatasetFee and InsolvencyCreation consume USDFC + // which starves piece-security and payment-security scenarios. type probe struct { name string fn func() } probes := []probe{ - {"EmptyDatasetFee", probeEmptyDatasetFee}, - {"InsolvencyCreation", probeInsolvencyCreation}, {"CrossPayerReplay", probeCrossPayerReplay}, {"BurstCreation", probeBurstCreation}, } pick := probes[rngIntn(len(probes))] - log.Printf("[pdp-griefing] dispatching: %s", pick.name) + log.Printf("[foc-griefing] dispatching: %s (epoch=%d, next after %d)", pick.name, currentEpoch, currentEpoch+griefCooldownEpochs) pick.fn() } @@ -342,7 +373,7 @@ func doGriefDispatch() { // that the client's USDFC balance in FPV1 decreases (fee extraction working). func probeEmptyDatasetFee() { if !foc.PingCurio(ctx) { - log.Printf("[sybil-fee-grief] curio not reachable, skipping") + log.Printf("[foc-griefing] curio not reachable, skipping") return } @@ -350,7 +381,7 @@ func probeEmptyDatasetFee() { if focCfg.SPKey == nil || focCfg.SPEthAddr == nil { focCfg.ReloadSPKey() if focCfg.SPKey == nil { - log.Printf("[sybil-fee-grief] SP key not available, skipping") + log.Printf("[foc-griefing] SP key not available, skipping") return } } @@ -361,7 +392,7 @@ func probeEmptyDatasetFee() { // 1. Snapshot client FPV1 funds BEFORE fundsBefore := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) if fundsBefore == nil || fundsBefore.Sign() == 0 { - log.Printf("[sybil-fee-grief] client funds exhausted (%v), skipping", fundsBefore) + log.Printf("[foc-griefing] client funds exhausted (%v), skipping", fundsBefore) return } @@ -377,7 +408,7 @@ func probeEmptyDatasetFee() { metadataKeys, metadataValues, ) if err != nil { - log.Printf("[sybil-fee-grief] EIP-712 signing failed: %v", err) + log.Printf("[foc-griefing] EIP-712 signing failed: %v", err) return } @@ -385,17 +416,17 @@ func probeEmptyDatasetFee() { recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) // 3. Submit via Curio HTTP API - log.Printf("[sybil-fee-grief] creating dataset: clientDataSetId=%s", clientDataSetId) + log.Printf("[foc-griefing] creating dataset: clientDataSetId=%s", clientDataSetId) txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) if err != nil { - log.Printf("[sybil-fee-grief] CreateDataSetHTTP failed: %v", err) + log.Printf("[foc-griefing] CreateDataSetHTTP failed: %v", err) return } // 4. Wait for on-chain confirmation onChainID, err := foc.WaitForDataSetCreation(ctx, txHash) if err != nil { - log.Printf("[sybil-fee-grief] WaitForDataSetCreation failed: %v", err) + log.Printf("[foc-griefing] WaitForDataSetCreation failed: %v", err) return } @@ -424,7 +455,7 @@ func probeEmptyDatasetFee() { created := griefRT.DSCreated griefMu.Unlock() - log.Printf("[pdp-griefing] dataset created: onChainID=%d fundsBefore=%s fundsAfter=%s delta=%s decreased=%v total=%d", + log.Printf("[foc-griefing] dataset created: onChainID=%d fundsBefore=%s fundsAfter=%s delta=%s decreased=%v total=%d", onChainID, fundsBefore, fundsAfter, delta, fundsDecreased, created) logGriefSPBalance() @@ -445,7 +476,7 @@ func logGriefSPBalance() { } s := griefSnap() - log.Printf("[sybil-fee-grief] SP balance=%s datasetsCreated=%d", bal, s.DSCreated) + log.Printf("[foc-griefing] SP balance=%s datasetsCreated=%d", bal, s.DSCreated) } // --------------------------------------------------------------------------- @@ -479,7 +510,7 @@ func probeInsolvencyCreation() { available := new(big.Int).Sub(funds, lockup) if available.Sign() <= 0 { // Already insolvent — try to create - log.Printf("[pdp-griefing] client already insolvent (funds=%s lockup=%s), attempting create", funds, lockup) + log.Printf("[foc-griefing] client already insolvent (funds=%s lockup=%s), attempting create", funds, lockup) } else { // 2. Drain all available funds calldata := foc.BuildCalldata(foc.SigWithdraw, @@ -487,10 +518,10 @@ func probeInsolvencyCreation() { foc.EncodeBigInt(available), ) - log.Printf("[pdp-griefing] draining client: withdrawing %s available USDFC", available) + log.Printf("[foc-griefing] draining client: withdrawing %s available USDFC", available) ok := foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, calldata, "pdp-griefing-drain") if !ok { - log.Printf("[pdp-griefing] withdrawal failed, skipping insolvency test") + log.Printf("[foc-griefing] withdrawal failed, skipping insolvency test") return } @@ -498,7 +529,7 @@ func probeInsolvencyCreation() { funds = foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) lockup = foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) available = new(big.Int).Sub(funds, lockup) - log.Printf("[pdp-griefing] post-drain: funds=%s lockup=%s available=%s", funds, lockup, available) + log.Printf("[foc-griefing] post-drain: funds=%s lockup=%s available=%s", funds, lockup, available) } // 3. Attempt dataset creation while insolvent @@ -512,19 +543,19 @@ func probeInsolvencyCreation() { metadataKeys, metadataValues, ) if err != nil { - log.Printf("[pdp-griefing] EIP-712 signing failed: %v", err) + log.Printf("[foc-griefing] EIP-712 signing failed: %v", err) return } extraData := encodeCreateDataSetExtra(s.ClientEth, clientDataSetId, metadataKeys, metadataValues, sig) recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) - log.Printf("[pdp-griefing] attempting dataset creation while insolvent (available=%s)", available) + log.Printf("[foc-griefing] attempting dataset creation while insolvent (available=%s)", available) txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) if err != nil { // HTTP-level rejection — Curio refused to submit the tx - log.Printf("[pdp-griefing] insolvent create rejected at HTTP: %v", err) + log.Printf("[foc-griefing] insolvent create rejected at HTTP: %v", err) assert.Sometimes(true, "insolvent client dataset creation rejected", map[string]any{ "available": available.String(), "error": err.Error(), @@ -534,13 +565,13 @@ func probeInsolvencyCreation() { onChainID, waitErr := foc.WaitForDataSetCreation(ctx, txHash) if waitErr != nil { // On-chain revert — correct behavior - log.Printf("[pdp-griefing] insolvent create reverted on-chain: %v", waitErr) + log.Printf("[foc-griefing] insolvent create reverted on-chain: %v", waitErr) assert.Sometimes(true, "insolvent client dataset creation rejected", map[string]any{ "available": available.String(), }) } else { // CRITICAL: dataset created with insolvent client - log.Printf("[pdp-griefing] CRITICAL: insolvent client created dataset! onChainID=%d available=%s", onChainID, available) + log.Printf("[foc-griefing] CRITICAL: insolvent client created dataset! onChainID=%d available=%s", onChainID, available) assert.Sometimes(false, "insolvent client dataset creation rejected", map[string]any{ "available": available.String(), "onChainID": onChainID, @@ -549,23 +580,12 @@ func probeInsolvencyCreation() { } } - // 4. Re-fund the secondary client for future probes - refundAmount := big.NewInt(griefUSDFCDeposit) - refundCalldata := foc.BuildCalldata(foc.SigTransfer, - foc.EncodeAddress(s.ClientEth), - foc.EncodeBigInt(refundAmount), - ) - foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, refundCalldata, "pdp-griefing-refund") - - // Re-deposit into FPV1 - depositCalldata := foc.BuildCalldata(foc.SigDeposit, - foc.EncodeAddress(focCfg.USDFCAddr), - foc.EncodeAddress(s.ClientEth), - foc.EncodeBigInt(refundAmount), - ) - foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, depositCalldata, "pdp-griefing-redeposit") - - log.Printf("[pdp-griefing] secondary client re-funded for future probes") + // 4. Re-fund the secondary client for future probes. + // We need to ensure available = funds - lockup is enough for the next + // dataset creation (sybilFee + minimumLockup ≈ 0.12 USDFC). Since lockup + // accumulates from previous datasets, we check after depositing and top up + // if needed. + griefRefundAndTopUp(s, node) } // --------------------------------------------------------------------------- @@ -604,7 +624,7 @@ func probeCrossPayerReplay() { metadataKeys, metadataValues, ) if err != nil { - log.Printf("[pdp-griefing] EIP-712 signing failed: %v", err) + log.Printf("[foc-griefing] EIP-712 signing failed: %v", err) return } @@ -612,12 +632,12 @@ func probeCrossPayerReplay() { extraData := encodeCreateDataSetExtra(focCfg.ClientEthAddr, clientDataSetId, metadataKeys, metadataValues, sig) recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) - log.Printf("[pdp-griefing] attempting cross-payer replay: signer=secondary payer=primary") + log.Printf("[foc-griefing] attempting cross-payer replay: signer=secondary payer=primary") txHash, err := foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) if err != nil { // Rejected at HTTP level - log.Printf("[pdp-griefing] cross-payer replay rejected at HTTP: %v", err) + log.Printf("[foc-griefing] cross-payer replay rejected at HTTP: %v", err) assert.Sometimes(true, "cross-payer signature replay rejected", nil) return } @@ -626,7 +646,7 @@ func probeCrossPayerReplay() { onChainID, waitErr := foc.WaitForDataSetCreation(ctx, txHash) if waitErr != nil { // On-chain revert — correct, signature didn't match payer - log.Printf("[pdp-griefing] cross-payer replay reverted on-chain: %v", waitErr) + log.Printf("[foc-griefing] cross-payer replay reverted on-chain: %v", waitErr) assert.Sometimes(true, "cross-payer signature replay rejected", nil) return } @@ -636,7 +656,7 @@ func probeCrossPayerReplay() { primaryCharged := primaryFundsAfter.Cmp(primaryFundsBefore) < 0 if primaryCharged { - log.Printf("[pdp-griefing] CRITICAL: cross-payer replay succeeded! Primary client charged without signing. onChainID=%d", onChainID) + log.Printf("[foc-griefing] CRITICAL: cross-payer replay succeeded! Primary client charged without signing. onChainID=%d", onChainID) assert.Sometimes(false, "cross-payer signature replay rejected", map[string]any{ "onChainID": onChainID, "primaryFundsBefore": primaryFundsBefore.String(), @@ -644,7 +664,7 @@ func probeCrossPayerReplay() { }) } else { // Creation succeeded but primary wasn't charged — maybe secondary was? - log.Printf("[pdp-griefing] cross-payer replay: tx succeeded but primary not charged (onChainID=%d)", onChainID) + log.Printf("[foc-griefing] cross-payer replay: tx succeeded but primary not charged (onChainID=%d)", onChainID) } } @@ -679,7 +699,7 @@ func probeBurstCreation() { accepted := 0 recordKeeper := "0x" + hex.EncodeToString(focCfg.FWSSAddr) - log.Printf("[pdp-griefing] starting burst creation: %d requests", burstSize) + log.Printf("[foc-griefing] starting burst creation: %d requests", burstSize) for i := 0; i < burstSize; i++ { clientDataSetId := new(big.Int).SetUint64(random.GetRandom()) @@ -700,7 +720,7 @@ func probeBurstCreation() { // Fire without waiting for confirmation _, err = foc.CreateDataSetHTTP(ctx, recordKeeper, hex.EncodeToString(extraData)) if err != nil { - log.Printf("[pdp-griefing] burst request %d/%d rejected: %v", i+1, burstSize, err) + log.Printf("[foc-griefing] burst request %d/%d rejected: %v", i+1, burstSize, err) } else { accepted++ } @@ -710,7 +730,7 @@ func probeBurstCreation() { fundsAfter := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) delta := new(big.Int).Sub(fundsBefore, fundsAfter) - log.Printf("[pdp-griefing] burst complete: accepted=%d/%d fundsBefore=%s fundsAfter=%s delta=%s", + log.Printf("[foc-griefing] burst complete: accepted=%d/%d fundsBefore=%s fundsAfter=%s delta=%s", accepted, burstSize, fundsBefore, fundsAfter, delta) // If all requests accepted with no rate limiting, log it @@ -738,6 +758,71 @@ func probeBurstCreation() { // --------------------------------------------------------------------------- // Progress +// griefRefundAndTopUp transfers USDFC from the primary client to the secondary +// client and deposits into FilecoinPay. After depositing, it checks whether +// available funds (funds - lockup) are sufficient for the next dataset creation. +// If not, it sends an additional top-up. This prevents the insolvency probe's +// drain from starving other probes that need to create datasets. +func griefRefundAndTopUp(s griefRuntime, node api.FullNode) { + refundAmount := big.NewInt(griefUSDFCDeposit) + + // Transfer USDFC from primary → secondary + refundCalldata := foc.BuildCalldata(foc.SigTransfer, + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + if !foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, refundCalldata, "foc-griefing-refund") { + log.Printf("[foc-griefing] WARN: refund transfer failed — secondary client may be drained") + return + } + + // Deposit into FilecoinPay + depositCalldata := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(refundAmount), + ) + if !foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, depositCalldata, "foc-griefing-redeposit") { + log.Printf("[foc-griefing] WARN: re-deposit failed") + return + } + + // Check if available funds are sufficient. Lockup accumulates from + // previously created datasets' rails, so even after depositing 0.5 USDFC, + // available might be near zero. Top up if needed. + funds := foc.ReadAccountFunds(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + lockup := foc.ReadAccountLockup(ctx, node, focCfg.FilPayAddr, focCfg.USDFCAddr, s.ClientEth) + if funds == nil || lockup == nil { + return + } + + available := new(big.Int).Sub(funds, lockup) + minRequired := big.NewInt(griefUSDFCDeposit) // 0.5 USDFC — covers sybilFee + lockup with margin + + if available.Cmp(minRequired) < 0 { + topUp := new(big.Int).Sub(minRequired, available) + log.Printf("[foc-griefing] available=%s < required=%s, topping up by %s", available, minRequired, topUp) + + topUpCalldata := foc.BuildCalldata(foc.SigTransfer, + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(topUp), + ) + if !foc.SendEthTxConfirmed(ctx, node, focCfg.ClientKey, focCfg.USDFCAddr, topUpCalldata, "foc-griefing-topup") { + log.Printf("[foc-griefing] WARN: top-up transfer failed") + return + } + + topUpDeposit := foc.BuildCalldata(foc.SigDeposit, + foc.EncodeAddress(focCfg.USDFCAddr), + foc.EncodeAddress(s.ClientEth), + foc.EncodeBigInt(topUp), + ) + foc.SendEthTxConfirmed(ctx, node, s.ClientKey, focCfg.FilPayAddr, topUpDeposit, "foc-griefing-topup-deposit") + } + + log.Printf("[foc-griefing] secondary client re-funded (available after=%s)", available) +} + // --------------------------------------------------------------------------- func logGriefProgress() { @@ -745,6 +830,6 @@ func logGriefProgress() { if s.ClientEth == nil { return } - log.Printf("[pdp-griefing] state=%s ds_created=%d initFunds=%v lastFunds=%v", + log.Printf("[foc-griefing] state=%s ds_created=%d initFunds=%v lastFunds=%v", s.State, s.DSCreated, s.InitFunds, s.LastFunds) } diff --git a/workload/cmd/stress-engine/main.go b/workload/cmd/stress-engine/main.go index 8d58580b..ae42436a 100644 --- a/workload/cmd/stress-engine/main.go +++ b/workload/cmd/stress-engine/main.go @@ -302,10 +302,10 @@ func buildDeck() { weightedAction{"DoFOCDeleteDataSet", "STRESS_WEIGHT_FOC_DELETE_DS", DoFOCDeleteDataSet, 0}, // PDP griefing and economic assertion probes weightedAction{"DoPDPGriefingProbe", "STRESS_WEIGHT_PDP_GRIEFING", DoPDPGriefingProbe, 2}, - // Security scenario: full piece lifecycle + attacks + // Security: piece lifecycle + attack probes weightedAction{"DoFOCPieceSecurityProbe", "STRESS_WEIGHT_FOC_PIECE_SECURITY", DoFOCPieceSecurityProbe, 2}, - // Security scenario: rail payment lifecycle + audit findings - weightedAction{"DoFOCPaymentSecurityProbe", "STRESS_WEIGHT_FOC_PAYMENT_SECURITY", DoFOCPaymentSecurityProbe, 2}, + // Security: independent payment/rail probes (settlement, withdrawTo, terminate, etc.) + weightedAction{"DoFOCPaymentSecurity", "STRESS_WEIGHT_FOC_PAYMENT_SECURITY", DoFOCPaymentSecurity, 2}, // Resilience scenario: Curio HTTP stress + orphan rails weightedAction{"DoFOCResilienceProbe", "STRESS_WEIGHT_FOC_RESILIENCE", DoFOCResilienceProbe, 1}, // Cross-node receipt consistency (catches consensus divergence on EVM txs) diff --git a/workload/internal/foc/selectors.go b/workload/internal/foc/selectors.go index a5c6187a..17aa0208 100644 --- a/workload/internal/foc/selectors.go +++ b/workload/internal/foc/selectors.go @@ -30,6 +30,10 @@ var ( SigGetRail = CalcSelector("getRail(uint256)") SigAllowance = CalcSelector("allowance(address,address)") + SigTerminateRail = CalcSelector("terminateRail(uint256)") + SigWithdrawTo = CalcSelector("withdrawTo(address,address,uint256)") + SigSettleTerminatedRailNoValidation = CalcSelector("settleTerminatedRailWithoutValidation(uint256)") + SigModifyRailLockup = CalcSelector("modifyRailLockup(uint256,uint256,uint256)") // ServiceProviderRegistry SigAddrToProvId = CalcSelector("addressToProviderId(address)") From 54985656b7bdeacb567660a22962e1e2521e8cb7 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Tue, 7 Apr 2026 12:59:08 +0000 Subject: [PATCH 6/8] fix(foc): cap resilience cycles + handle deletion gas failure gracefully - Piece security: skip to attack phase on deletion tx failure instead of retrying forever. schedulePieceDeletions through FWSS callback chain costs ~29.7M of 30M gas on FVM (known issue, already reported). Added Sometimes assertion to track when/if this gets fixed. - Resilience: cap at 2 cycles (was unlimited). Each cycle creates an orphan dataset costing ~0.06 USDFC sybil fee, draining the secondary client's funds and causing subsequent scenarios to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cmd/stress-engine/foc_piece_security.go | 20 ++++++++++++++++++- workload/cmd/stress-engine/foc_resilience.go | 17 ++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/workload/cmd/stress-engine/foc_piece_security.go b/workload/cmd/stress-engine/foc_piece_security.go index 209e7c6c..9fd2f017 100644 --- a/workload/cmd/stress-engine/foc_piece_security.go +++ b/workload/cmd/stress-engine/foc_piece_security.go @@ -299,10 +299,28 @@ func pieceSecDoDelete(gs griefRuntime) { ok := foc.SendEthTxConfirmed(ctx, node, focCfg.SPKey, focCfg.PDPAddr, calldata, "foc-piece-security-delete") if !ok { - log.Printf("[foc-piece-security] deletion tx failed, will retry") + // schedulePieceDeletions through FWSS is extremely gas-heavy on FVM + // (~29.7M gas for the cross-contract callback chain). It may hit the + // 30M gas limit and revert. This is a known FVM cost issue, not a + // contract logic bug. Skip to attack phase rather than retrying forever. + log.Printf("[foc-piece-security] deletion tx failed (likely gas limit on FVM cross-contract calls), skipping to attack phase") + assert.Sometimes(false, "Piece deletion via FWSS callback succeeds", map[string]any{ + "pieceID": s.PieceID, + "dataSetID": gs.LastOnChainDSID, + "note": "schedulePieceDeletions uses ~29.7M of 30M gas on FVM", + }) + pieceSecMu.Lock() + pieceSec.Phase = pieceSecAttack + pieceSecMu.Unlock() return } + log.Printf("[foc-piece-security] deletion succeeded: pieceID=%d", s.PieceID) + assert.Sometimes(true, "Piece deletion via FWSS callback succeeds", map[string]any{ + "pieceID": s.PieceID, + "dataSetID": gs.LastOnChainDSID, + }) + // curio#1039: immediately retrieve after deletion scheduled retrieveData, retrieveErr := foc.DownloadPiece(ctx, s.PieceCID) if retrieveErr != nil { diff --git a/workload/cmd/stress-engine/foc_resilience.go b/workload/cmd/stress-engine/foc_resilience.go index a0bc34e9..b6bd2e29 100644 --- a/workload/cmd/stress-engine/foc_resilience.go +++ b/workload/cmd/stress-engine/foc_resilience.go @@ -85,6 +85,11 @@ func resSnap() resRuntime { // DoFOCResilienceProbe — deck entry // --------------------------------------------------------------------------- +// resMaxCycles limits how many resilience cycles run. Each cycle creates an +// orphan dataset (~0.06 USDFC sybil fee) and runs an HTTP barrage. After a +// few cycles the assertions are satisfied and further cycles just drain funds. +const resMaxCycles = 2 + func DoFOCResilienceProbe() { if focCfg == nil || focCfg.ClientKey == nil { return @@ -98,14 +103,18 @@ func DoFOCResilienceProbe() { return } - if !foc.PingCurio(ctx) { - return - } - resSecMu.Lock() + if resSec.Cycles >= resMaxCycles { + resSecMu.Unlock() + return // resilience probes have run enough + } state := resSec.State resSecMu.Unlock() + if !foc.PingCurio(ctx) { + return + } + switch state { case resInit: resDoHTTPStress() From 666c2ad9045bcac038f7597ff0a855df633e096d Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Tue, 7 Apr 2026 15:13:07 +0000 Subject: [PATCH 7/8] feat(foc): sidecar economic invariants + settle-mid-period probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidecar: 4 new continuous invariants (15 total): - checkSettlementMonotonicity: settledUpTo never goes backwards on any rail. Regression guard for filecoin-pay#134 (settlement halt on zero-rate segment). - checkDeletedDatasetFullySettled: deleted datasets have settledUpTo >= endEpoch. Regression guard for filecoin-services#375 (delete without full settlement). - checkOperatorApprovalConsistency: operator rateUsage <= rateAllowance and lockupUsage <= lockupAllowance. Regression guard for filecoin-pay#137/#274 (operator lockup leak, #274 still OPEN). - checkLockupIncreasesOnPieceAdd: when activePieceCount increases, payer lockup must also increase. Regression guard for filecoin-services#350 (underfunding window on piecesAdded). Stress-engine: payProbeSettleMidPeriod — attempts settlement during an open proving period (before deadline). Verifies settledUpTo does not advance past the period boundary. Regression for filecoin-services#416. Helpers: ReadOperatorApprovals in eth.go for sidecar operator checks. Debug: revert reason capture via eth_call replay on settle and deletion failures. Confirmed settleRail revert is RailInactiveOrSettled (wrong rail — burn rail vs PDP rail). Confirmed piece deletion revert via Curio HTTP is ExtraDataRequired (client signature not provided). Co-Authored-By: Claude Opus 4.6 (1M context) --- workload/cmd/foc-sidecar/assertions.go | 168 ++++++++++++++++++ workload/cmd/foc-sidecar/main.go | 4 + workload/cmd/foc-sidecar/state.go | 13 +- .../cmd/stress-engine/foc_payment_security.go | 85 ++++++++- .../cmd/stress-engine/foc_piece_security.go | 5 +- workload/internal/foc/eth.go | 20 ++- 6 files changed, 288 insertions(+), 7 deletions(-) diff --git a/workload/cmd/foc-sidecar/assertions.go b/workload/cmd/foc-sidecar/assertions.go index b699f703..f4f2bc1f 100644 --- a/workload/cmd/foc-sidecar/assertions.go +++ b/workload/cmd/foc-sidecar/assertions.go @@ -374,6 +374,174 @@ func checkDeletedDatasetRailTerminated(ctx context.Context, node api.FullNode, c } } +// checkSettlementMonotonicity verifies that settledUpTo for every tracked +// rail only advances forward. If it ever decreases, settlement accounting +// is broken. Regression for filecoin-pay#134 (settlement halt on zero-rate). +func checkSettlementMonotonicity(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.FilPayAddr == nil { + return + } + + state.mu.Lock() + defer state.mu.Unlock() + + for _, rail := range state.Rails { + railData, err := foc.ReadRailFull(ctx, node, cfg.FilPayAddr, rail.RailID) + if err != nil || len(railData) < 320 { + continue + } + + // settledUpTo is at word index 8 (bytes 256-288) + settledUpTo := new(big.Int).SetBytes(railData[256:288]).Uint64() + + if rail.LastSeenSettledUpTo > 0 && settledUpTo < rail.LastSeenSettledUpTo { + log.Printf("[settlement-monotonicity] VIOLATION: rail %d settledUpTo went backwards: %d → %d", + rail.RailID, rail.LastSeenSettledUpTo, settledUpTo) + assert.Always(false, "Rail settledUpTo only advances forward", map[string]any{ + "railID": rail.RailID, + "previous": rail.LastSeenSettledUpTo, + "current": settledUpTo, + }) + } + + rail.LastSeenSettledUpTo = settledUpTo + } +} + +// checkDeletedDatasetFullySettled verifies that deleted datasets have their +// PDP rail fully settled (settledUpTo >= endEpoch). If not, the dataset was +// deleted without completing payment. Regression for filecoin-services#375. +func checkDeletedDatasetFullySettled(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.FilPayAddr == nil { + return + } + + datasets := state.GetDatasets() + for _, ds := range datasets { + if !ds.Deleted || ds.PDPRailID == 0 { + continue + } + + railData, err := foc.ReadRailFull(ctx, node, cfg.FilPayAddr, ds.PDPRailID) + if err != nil || len(railData) < 320 { + continue + } + + endEpoch := new(big.Int).SetBytes(railData[224:256]) // word 7 + settledUpTo := new(big.Int).SetBytes(railData[256:288]) // word 8 + + if endEpoch.Sign() == 0 { + continue // rail not terminated yet (finality lag) + } + + fullySettled := settledUpTo.Cmp(endEpoch) >= 0 + assert.Sometimes(fullySettled, "Deleted dataset rail is fully settled", map[string]any{ + "dataSetId": ds.DataSetID, + "pdpRailId": ds.PDPRailID, + "settledUpTo": settledUpTo.String(), + "endEpoch": endEpoch.String(), + }) + + if !fullySettled { + log.Printf("[deleted-rail-settled] dataset %d rail %d: settledUpTo=%s < endEpoch=%s", + ds.DataSetID, ds.PDPRailID, settledUpTo, endEpoch) + } + } +} + +// checkOperatorApprovalConsistency verifies that operator rate and lockup +// usage never exceeds the approved allowances. Regression for filecoin-pay#137/#274 +// (operator lockup leak on rail finalization — #274 still OPEN). +func checkOperatorApprovalConsistency(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.FilPayAddr == nil || cfg.USDFCAddr == nil || cfg.FWSSAddr == nil { + return + } + + payers := state.GetTrackedPayers() + for _, payer := range payers { + rateUsage, lockupUsage := foc.ReadOperatorApprovals(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, payer, cfg.FWSSAddr) + + // Read allowances (words 1 and 2 of the 6-word return) + calldata := foc.BuildCalldata(foc.SigOperatorApprovals, foc.EncodeAddress(cfg.USDFCAddr), foc.EncodeAddress(payer), foc.EncodeAddress(cfg.FWSSAddr)) + result, err := foc.EthCallRaw(ctx, node, cfg.FilPayAddr, calldata) + if err != nil || len(result) < 192 { + continue + } + rateAllowance := new(big.Int).SetBytes(result[32:64]) // word 1 + lockupAllowance := new(big.Int).SetBytes(result[64:96]) // word 2 + + if rateAllowance.Sign() > 0 { + rateOK := rateUsage.Cmp(rateAllowance) <= 0 + assert.Always(rateOK, "Operator rate usage within allowance", map[string]any{ + "payer": fmt.Sprintf("0x%x", payer), + "rateUsage": rateUsage.String(), + "rateAllowance": rateAllowance.String(), + }) + if !rateOK { + log.Printf("[operator-approval] VIOLATION: payer=%x rateUsage=%s > rateAllowance=%s", payer, rateUsage, rateAllowance) + } + } + + if lockupAllowance.Sign() > 0 { + lockupOK := lockupUsage.Cmp(lockupAllowance) <= 0 + assert.Always(lockupOK, "Operator lockup usage within allowance", map[string]any{ + "payer": fmt.Sprintf("0x%x", payer), + "lockupUsage": lockupUsage.String(), + "lockupAllowance": lockupAllowance.String(), + }) + if !lockupOK { + log.Printf("[operator-approval] VIOLATION: payer=%x lockupUsage=%s > lockupAllowance=%s", payer, lockupUsage, lockupAllowance) + } + } + } +} + +// checkLockupIncreasesOnPieceAdd verifies that when activePieceCount increases +// for a dataset, the payer's lockup also increases (rate change applied +// atomically with piece addition). Regression for filecoin-services#350. +func checkLockupIncreasesOnPieceAdd(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { + if cfg.PDPAddr == nil || cfg.FilPayAddr == nil || cfg.USDFCAddr == nil { + return + } + + state.mu.Lock() + defer state.mu.Unlock() + + for _, ds := range state.Datasets { + if ds.Deleted { + continue + } + + dsIDBytes := foc.EncodeBigInt(bigIntFromUint64(ds.DataSetID)) + activeCount, err := foc.EthCallUint256(ctx, node, cfg.PDPAddr, foc.BuildCalldata(foc.SigGetActivePieceCount, dsIDBytes)) + if err != nil || activeCount == nil { + continue + } + + currentCount := activeCount.Uint64() + currentLockup := foc.ReadAccountLockup(ctx, node, cfg.FilPayAddr, cfg.USDFCAddr, ds.Payer) + + // If piece count increased since last poll, lockup should have also increased + if ds.LastSeenPieceCount > 0 && currentCount > ds.LastSeenPieceCount && ds.LastSeenPayerLockup != nil { + lockupIncreased := currentLockup.Cmp(ds.LastSeenPayerLockup) >= 0 + assert.Sometimes(lockupIncreased, "Lockup increases when pieces are added", map[string]any{ + "dataSetId": ds.DataSetID, + "piecesBefore": ds.LastSeenPieceCount, + "piecesAfter": currentCount, + "lockupBefore": ds.LastSeenPayerLockup.String(), + "lockupAfter": currentLockup.String(), + }) + if !lockupIncreased { + log.Printf("[lockup-on-add] dataset %d: pieces %d→%d but lockup %s→%s (decreased!)", + ds.DataSetID, ds.LastSeenPieceCount, currentCount, ds.LastSeenPayerLockup, currentLockup) + } + } + + ds.LastSeenPieceCount = currentCount + ds.LastSeenPayerLockup = currentLockup + } +} + // checkRateConsistency verifies that active datasets with pieces have a // non-zero payment rate on their PDP rail. func checkRateConsistency(ctx context.Context, node api.FullNode, cfg *foc.Config, state *SidecarState) { diff --git a/workload/cmd/foc-sidecar/main.go b/workload/cmd/foc-sidecar/main.go index 8cdb9f7b..b485d741 100644 --- a/workload/cmd/foc-sidecar/main.go +++ b/workload/cmd/foc-sidecar/main.go @@ -91,6 +91,10 @@ func main() { checkRateConsistency(ctx, node, cfg, state) checkLockupNeverExceedsFunds(ctx, node, cfg, state) checkDeletedDatasetRailTerminated(ctx, node, cfg, state) + checkSettlementMonotonicity(ctx, node, cfg, state) + checkDeletedDatasetFullySettled(ctx, node, cfg, state) + checkOperatorApprovalConsistency(ctx, node, cfg, state) + checkLockupIncreasesOnPieceAdd(ctx, node, cfg, state) lastPolledBlock = finalizedHeight pollCount++ diff --git a/workload/cmd/foc-sidecar/state.go b/workload/cmd/foc-sidecar/state.go index 30faebe1..07676e00 100644 --- a/workload/cmd/foc-sidecar/state.go +++ b/workload/cmd/foc-sidecar/state.go @@ -29,14 +29,19 @@ type DatasetInfo struct { LastSeenChallengeEpoch uint64 // last observed getNextChallengeEpoch value LastSeenProvenEpoch uint64 // last observed getDataSetLastProvenEpoch value ChallengeEpochStale int // consecutive polls where challenge epoch didn't advance + + // Piece-add lockup tracking (for checkLockupIncreasesOnPieceAdd) + LastSeenPieceCount uint64 // last observed activePieceCount + LastSeenPayerLockup *big.Int // payer lockup when piece count was last read } // RailInfo holds state for a tracked payment rail. type RailInfo struct { - RailID uint64 - Token []byte - From []byte - To []byte + RailID uint64 + Token []byte + From []byte + To []byte + LastSeenSettledUpTo uint64 // for settlement monotonicity check } // NewSidecarState creates an initialized SidecarState. diff --git a/workload/cmd/stress-engine/foc_payment_security.go b/workload/cmd/stress-engine/foc_payment_security.go index 8fc3fc7c..bc6f745e 100644 --- a/workload/cmd/stress-engine/foc_payment_security.go +++ b/workload/cmd/stress-engine/foc_payment_security.go @@ -72,6 +72,7 @@ func DoFOCPaymentSecurity() { {"DirectTerminateRail", payProbeDirectTerminateRail}, {"SettleTerminatedRail", payProbeSettleTerminatedRail}, {"WithdrawAll", payProbeWithdrawAll}, + {"SettleMidPeriod", payProbeSettleMidPeriod}, } pick := probes[rngIntn(len(probes))] @@ -159,7 +160,10 @@ func payProbeSettleLockup(gs griefRuntime) { ) ok := foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, calldata, "foc-payment-security-settle") if !ok { - log.Printf("[foc-payment-security] settle failed for railID=%s", railID) + // Replay via eth_call to capture revert reason + revertData, revertErr := foc.EthCallRaw(ctx, node, focCfg.FilPayAddr, calldata) + log.Printf("[foc-payment-security] settle REVERTED for railID=%s epoch=%s revertData=%x revertErr=%v", + railID, settleEpoch, revertData, revertErr) return } @@ -452,6 +456,85 @@ func payProbeWithdrawAll(gs griefRuntime) { } } +// --------------------------------------------------------------------------- +// Probe: Settle Mid-Period (filecoin-services#416/#417) +// +// Attempts settlement during an open proving period (deadline not passed). +// The contract should block — settledUpTo must NOT advance past the previous +// period boundary. If it does, the SP gets paid for unproven epochs. +// --------------------------------------------------------------------------- + +func payProbeSettleMidPeriod(gs griefRuntime) { + railID := payFindRail(gs) + if railID == nil { + return + } + if gs.LastOnChainDSID == 0 || focCfg.PDPAddr == nil { + return + } + + node := focNode() + head, err := node.ChainHead(ctx) + if err != nil { + return + } + currentEpoch := int64(head.Height()) + + // Check if we're mid-period (deadline not passed yet) + dsIDBytes := foc.EncodeBigInt(big.NewInt(int64(gs.LastOnChainDSID))) + nextChallenge, err := foc.EthCallUint256(ctx, node, focCfg.PDPAddr, + foc.BuildCalldata(foc.SigGetNextChallengeEpoch, dsIDBytes)) + if err != nil || nextChallenge == nil || nextChallenge.Sign() == 0 { + return + } + + if currentEpoch >= nextChallenge.Int64() { + // Deadline already passed — not a mid-period test + log.Printf("[foc-payment-security] SettleMidPeriod: deadline already passed (epoch=%d, challenge=%s)", currentEpoch, nextChallenge) + return + } + + // Read settledUpTo BEFORE + railData, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, railID.Uint64()) + if err != nil || len(railData) < 320 { + return + } + settledBefore := new(big.Int).SetBytes(railData[256:288]) // word 8 + + // Attempt settlement at current epoch (mid-period) + settleCalldata := foc.BuildCalldata(foc.SigSettleRail, + foc.EncodeBigInt(railID), + foc.EncodeBigInt(big.NewInt(currentEpoch)), + ) + foc.SendEthTxConfirmed(ctx, node, gs.ClientKey, focCfg.FilPayAddr, settleCalldata, "foc-payment-security-settle-mid") + + // Read settledUpTo AFTER + railDataAfter, err := foc.ReadRailFull(ctx, node, focCfg.FilPayAddr, railID.Uint64()) + if err != nil || len(railDataAfter) < 320 { + return + } + settledAfter := new(big.Int).SetBytes(railDataAfter[256:288]) + + // settledUpTo should NOT have advanced past the previous period boundary + // (it may have advanced to a completed period boundary, but not into the open period) + noAdvancePastChallenge := settledAfter.Int64() < nextChallenge.Int64() + assert.Sometimes(noAdvancePastChallenge, "Settlement blocked during open proving period", map[string]any{ + "railID": railID.String(), + "settledBefore": settledBefore.String(), + "settledAfter": settledAfter.String(), + "nextChallenge": nextChallenge.String(), + "currentEpoch": currentEpoch, + }) + + if !noAdvancePastChallenge { + log.Printf("[foc-payment-security] ANOMALY: settlement advanced past open deadline! settled=%s challenge=%s", + settledAfter, nextChallenge) + } else { + log.Printf("[foc-payment-security] SettleMidPeriod: correctly blocked (settled=%s, challenge=%s, epoch=%d)", + settledAfter, nextChallenge, currentEpoch) + } +} + // --------------------------------------------------------------------------- // Progress // --------------------------------------------------------------------------- diff --git a/workload/cmd/stress-engine/foc_piece_security.go b/workload/cmd/stress-engine/foc_piece_security.go index 9fd2f017..fdb60d95 100644 --- a/workload/cmd/stress-engine/foc_piece_security.go +++ b/workload/cmd/stress-engine/foc_piece_security.go @@ -303,7 +303,10 @@ func pieceSecDoDelete(gs griefRuntime) { // (~29.7M gas for the cross-contract callback chain). It may hit the // 30M gas limit and revert. This is a known FVM cost issue, not a // contract logic bug. Skip to attack phase rather than retrying forever. - log.Printf("[foc-piece-security] deletion tx failed (likely gas limit on FVM cross-contract calls), skipping to attack phase") + // Replay via eth_call to capture revert reason + revertData, revertErr := foc.EthCallRaw(ctx, node, focCfg.PDPAddr, calldata) + log.Printf("[foc-piece-security] deletion REVERTED: revertData=%x revertErr=%v", revertData, revertErr) + log.Printf("[foc-piece-security] deletion tx failed, skipping to attack phase") assert.Sometimes(false, "Piece deletion via FWSS callback succeeds", map[string]any{ "pieceID": s.PieceID, "dataSetID": gs.LastOnChainDSID, diff --git a/workload/internal/foc/eth.go b/workload/internal/foc/eth.go index 645c4277..55f965e9 100644 --- a/workload/internal/foc/eth.go +++ b/workload/internal/foc/eth.go @@ -401,8 +401,26 @@ func ReadAllowance(ctx context.Context, node api.FullNode, tokenAddr, ownerAddr, return result } +// ReadOperatorApprovals calls operatorApprovals(token, client, operator) on FilecoinPay. +// Returns (rateUsage, lockupUsage) — words 3 and 4 of the 6-word return tuple: +// (approved bool, rateAllowance, lockupAllowance, rateUsage, lockupUsage, maxLockupPeriod) +func ReadOperatorApprovals(ctx context.Context, node api.FullNode, filPayAddr, tokenAddr, clientAddr, operatorAddr []byte) (rateUsage, lockupUsage *big.Int) { + calldata := BuildCalldata(SigOperatorApprovals, EncodeAddress(tokenAddr), EncodeAddress(clientAddr), EncodeAddress(operatorAddr)) + result, err := EthCallRaw(ctx, node, filPayAddr, calldata) + if err != nil { + log.Printf("[foc] ReadOperatorApprovals failed: %v", err) + return big.NewInt(0), big.NewInt(0) + } + if len(result) < 192 { // need 6 words + return big.NewInt(0), big.NewInt(0) + } + rateUsage = new(big.Int).SetBytes(result[96:128]) // word 3 + lockupUsage = new(big.Int).SetBytes(result[128:160]) // word 4 + return +} + // ReadRailFull calls getRail(railId) and returns the full raw result (12 words / 384 bytes). -// Layout: token|from|to|operator|paymentRate|arbiter|createdEpoch|endEpoch|... +// Layout: token|from|to|operator|paymentRate|arbiter|createdEpoch|endEpoch|settledUpTo|lockupPeriod|lockupFixed|commissionRateBps func ReadRailFull(ctx context.Context, node api.FullNode, filPayAddr []byte, railID uint64) ([]byte, error) { calldata := BuildCalldata(SigGetRail, EncodeBigInt(new(big.Int).SetUint64(railID))) return EthCallRaw(ctx, node, filPayAddr, calldata) From 6a91f7f4f5c89cc57ab3cff11a697c1c50721a06 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Tue, 7 Apr 2026 15:15:31 +0000 Subject: [PATCH 8/8] chore(foc): restore balanced FOC deck weights for full testing Restore all consensus, steady-state, and security vectors to production weights. Previously zeroed for isolated new-vector testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yaml | 104 ++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index adb370e5..a67b0e8f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -143,61 +143,61 @@ services: - STRESS_FOREST_RPC_PORT=3456 - STRESS_KEYSTORE_PATH=/shared/configs/stress_keystore.json - STRESS_WAIT_HEIGHT=10 - # --- Consensus / health-check vectors (zeroed for new-vector-only testing) --- - - STRESS_WEIGHT_TIPSET_CONSENSUS=0 - - STRESS_WEIGHT_HEIGHT_PROGRESSION=0 - - STRESS_WEIGHT_PEER_COUNT=0 - - STRESS_WEIGHT_HEAD_COMPARISON=0 - - STRESS_WEIGHT_STATE_ROOT=0 - - STRESS_WEIGHT_STATE_AUDIT=0 - - STRESS_WEIGHT_F3_MONITOR=0 - - STRESS_WEIGHT_F3_AGREEMENT=0 - - STRESS_WEIGHT_REORG=0 - - STRESS_WEIGHT_POWER_SLASH=0 - - FUZZER_ENABLED=0 - - STRESS_CONSENSUS_TEST=0 + # --- Consensus / health-check vectors (always active in both profiles) --- + - STRESS_WEIGHT_TIPSET_CONSENSUS=3 # cross-node tipset agreement (Sometimes) + - STRESS_WEIGHT_HEIGHT_PROGRESSION=2 # chain height advances + - STRESS_WEIGHT_PEER_COUNT=1 # node peer connectivity + - STRESS_WEIGHT_HEAD_COMPARISON=3 # cross-node chain head match (Sometimes) + - STRESS_WEIGHT_STATE_ROOT=4 # cross-node state root match (Sometimes) + - STRESS_WEIGHT_STATE_AUDIT=3 # full state tree audit + - STRESS_WEIGHT_F3_MONITOR=2 # passive F3 health monitor + - STRESS_WEIGHT_F3_AGREEMENT=3 # cross-node F3 certificate consistency + - STRESS_WEIGHT_REORG=0 # power-aware reorg testing (disabled — consensus lifecycle handles partitions) + - STRESS_WEIGHT_POWER_SLASH=0 # disabled for FOC — causes miner disruption + - FUZZER_ENABLED=0 # protocol fuzzer off + - STRESS_CONSENSUS_TEST=0 # n-split disabled for FOC — disrupts Curio # - # --- Non-FOC stress vectors (all zeroed) --- - - STRESS_WEIGHT_DEPLOY=0 - - STRESS_WEIGHT_CONTRACT_CALL=0 - - STRESS_WEIGHT_SELFDESTRUCT=0 - - STRESS_WEIGHT_CONTRACT_RACE=0 - - STRESS_WEIGHT_TRANSFER=0 - - STRESS_WEIGHT_GAS_WAR=0 - - STRESS_WEIGHT_NONCE_RACE=0 - - STRESS_WEIGHT_HEAVY_COMPUTE=0 - - STRESS_WEIGHT_MAX_BLOCK_GAS=0 - - STRESS_WEIGHT_LOG_BLASTER=0 - - STRESS_WEIGHT_MEMORY_BOMB=0 - - STRESS_WEIGHT_STORAGE_SPAM=0 - - STRESS_WEIGHT_MSG_ORDERING=0 - - STRESS_WEIGHT_NONCE_BOMBARD=0 - - STRESS_WEIGHT_ADVERSARIAL=0 - - STRESS_WEIGHT_GAS_EXHAUST=0 - - STRESS_WEIGHT_RECEIPT_AUDIT=0 - - STRESS_WEIGHT_ACTOR_MIGRATION=0 - - STRESS_WEIGHT_ACTOR_LIFECYCLE=0 - # --- FOC vectors --- - # REQUIRED: lifecycle must reach Ready + griefing must set up secondary client + # --- Non-FOC stress vectors (skipped when FOC active, kept for reference) --- + - STRESS_WEIGHT_DEPLOY=1 # Init actor & state tree growth via EAM.CreateExternal + - STRESS_WEIGHT_CONTRACT_CALL=1 # deep recursion, delegatecall, external recursive calls + - STRESS_WEIGHT_SELFDESTRUCT=0 # actor destruction state consistency across nodes + - STRESS_WEIGHT_CONTRACT_RACE=2 # conflicting txs to diff nodes — state divergence during forks + - STRESS_WEIGHT_TRANSFER=2 # FIL transfers (state changes for forks to reconcile) + - STRESS_WEIGHT_GAS_WAR=1 # mempool replacement across forks + - STRESS_WEIGHT_NONCE_RACE=1 # gas-premium race across different nodes + - STRESS_WEIGHT_HEAVY_COMPUTE=1 # intra-node state recomputation verification + - STRESS_WEIGHT_MAX_BLOCK_GAS=0 # maxes out block gas + - STRESS_WEIGHT_LOG_BLASTER=0 # receipt storage, bloom filters, event indexing + - STRESS_WEIGHT_MEMORY_BOMB=0 # FVM memory accounting + - STRESS_WEIGHT_STORAGE_SPAM=0 # state trie (HAMT), SplitStore + - STRESS_WEIGHT_MSG_ORDERING=1 # cross-node message replacement/mempool ordering + - STRESS_WEIGHT_NONCE_BOMBARD=0 # N+x gap handling & out-of-order execution + - STRESS_WEIGHT_ADVERSARIAL=0 # double-spends (handled by consensus lifecycle) + - STRESS_WEIGHT_GAS_EXHAUST=0 # high-gas call competing with small msgs + - STRESS_WEIGHT_RECEIPT_AUDIT=4 # asserts receipt fields match across every node + - STRESS_WEIGHT_ACTOR_MIGRATION=1 # burst-creates & deletes actors, stresses HAMT during forks + - STRESS_WEIGHT_ACTOR_LIFECYCLE=1 # full actor lifecycle + # --- FOC (Filecoin On-Chain Cloud) vectors --- + # SETUP: drives the sequential state machine one step per pick - STRESS_WEIGHT_FOC_LIFECYCLE=6 - # Minimum steady-state: need pieces uploaded/added for piece security scenario - - STRESS_WEIGHT_FOC_UPLOAD=2 - - STRESS_WEIGHT_FOC_ADD_PIECES=2 - - STRESS_WEIGHT_FOC_MONITOR=1 # keep one for observability - - STRESS_WEIGHT_FOC_RETRIEVE=0 - - STRESS_WEIGHT_FOC_TRANSFER=0 - - STRESS_WEIGHT_FOC_SETTLE=0 - - STRESS_WEIGHT_FOC_WITHDRAW=0 - - STRESS_WEIGHT_FOC_DELETE_PIECE=0 - - STRESS_WEIGHT_FOC_DELETE_DS=0 + # STEADY-STATE: only execute once lifecycle reaches Ready + - STRESS_WEIGHT_FOC_UPLOAD=4 # upload random data to Curio PDP API + - STRESS_WEIGHT_FOC_ADD_PIECES=3 # add uploaded pieces to on-chain proofset + - STRESS_WEIGHT_FOC_MONITOR=4 # query proofset health + USDFC balances + - STRESS_WEIGHT_FOC_RETRIEVE=2 # download piece and verify CID integrity + - STRESS_WEIGHT_FOC_TRANSFER=2 # ERC-20 USDFC transfer (client → deployer) + - STRESS_WEIGHT_FOC_SETTLE=2 # settle active payment rail + - STRESS_WEIGHT_FOC_WITHDRAW=2 # withdraw USDFC from FilecoinPay + # DESTRUCTIVE + - STRESS_WEIGHT_FOC_DELETE_PIECE=1 # schedule piece deletion from proofset + - STRESS_WEIGHT_FOC_DELETE_DS=0 # delete entire dataset + reset lifecycle - STRESS_WEIGHT_REORG_CHAOS=0 # disable — partitions lotus0 which stalls all FOC ops - # - # REQUIRED: griefing setup must complete before security scenarios fire - - STRESS_WEIGHT_PDP_GRIEFING=6 - # *** NEW VECTORS UNDER TEST *** - - STRESS_WEIGHT_FOC_PIECE_SECURITY=4 # piece lifecycle + attack probes - - STRESS_WEIGHT_FOC_PAYMENT_SECURITY=4 # rail settlement + audit L01/L04/L06/#288 - - STRESS_WEIGHT_FOC_RESILIENCE=3 # Curio HTTP stress + orphan rail + # ADVERSARIAL: griefing probes (cooldown after first dataset creation) + - STRESS_WEIGHT_PDP_GRIEFING=4 # fee extraction, insolvency, replay, burst attacks + # SECURITY: piece lifecycle, payment rail, resilience + - STRESS_WEIGHT_FOC_PIECE_SECURITY=2 # piece lifecycle + attack probes + - STRESS_WEIGHT_FOC_PAYMENT_SECURITY=2 # rail settlement + audit L01/L04/L06/#288 + - STRESS_WEIGHT_FOC_RESILIENCE=1 # Curio HTTP stress + orphan rail # - CURIO_PDP_URL=http://curio:80