diff --git a/service/dealpusher/ddo_integration_test.go b/service/dealpusher/ddo_integration_test.go new file mode 100644 index 000000000..f8d7831ea --- /dev/null +++ b/service/dealpusher/ddo_integration_test.go @@ -0,0 +1,121 @@ +package dealpusher + +import ( + "context" + "testing" + "time" + + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +func startCalibnetFork(t *testing.T) *testutil.AnvilInstance { + t.Helper() + return testutil.StartAnvil(t, testutil.CalibnetRPC) +} + +// TestIntegration_DDOClientConnectivity verifies that OnChainDDO can connect +// to a calibnet fork and detect the correct chain ID. +func TestIntegration_DDOClientConnectivity(t *testing.T) { + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Verify raw RPC connectivity and chain ID + ethClient, err := ethclient.DialContext(ctx, rpcURL) + require.NoError(t, err) + defer ethClient.Close() + + chainID, err := ethClient.ChainID(ctx) + require.NoError(t, err) + require.EqualValues(t, testutil.CalibnetChainID, chainID.Int64()) + + // Initialize OnChainDDO client with calibnet contract addresses + ddo, err := NewOnChainDDO(ctx, rpcURL, + testutil.CalibnetDDOContract, + testutil.CalibnetPaymentsContract, + testutil.CalibnetUSDFC, + ) + require.NoError(t, err) + defer ddo.Close() + + require.EqualValues(t, testutil.CalibnetChainID, ddo.chainID.Int64()) + t.Logf("DDO client connected: chainID=%d, ddo=%s, payments=%s", + ddo.chainID, ddo.ddoContractAddr.Hex(), ddo.paymentsContractAddr.Hex()) +} + +// TestIntegration_DDOWalletFunding verifies the testutil wallet funding helper +// works against an Anvil fork. +func TestIntegration_DDOWalletFunding(t *testing.T) { + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Generate a fresh test wallet + _, addr := testutil.GenerateTestKey(t) + + // Fund it with 10 ETH (FIL on calibnet) + testutil.FundEVMWallet(t, rpcURL, addr, testutil.OneEther) + + // Verify the balance + client, err := ethclient.DialContext(ctx, rpcURL) + require.NoError(t, err) + defer client.Close() + + balance, err := client.BalanceAt(ctx, addr, nil) + require.NoError(t, err) + require.Equal(t, testutil.OneEther, balance) + t.Logf("funded wallet %s with %s wei", addr.Hex(), balance.String()) +} + +// TestIntegration_DDOValidateSP attempts to validate an SP on calibnet. +// This test exercises the contract read path. Without a registered SP, +// ValidateSP returns an empty (inactive) config — we verify the call +// succeeds and returns a well-formed response. +func TestIntegration_DDOValidateSP(t *testing.T) { + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ddo, err := NewOnChainDDO(ctx, rpcURL, + testutil.CalibnetDDOContract, + testutil.CalibnetPaymentsContract, + testutil.CalibnetUSDFC, + ) + require.NoError(t, err) + defer ddo.Close() + + // Use a known-invalid provider ID — should return inactive, not error. + // When a real calibnet SP is registered, this test should be updated + // to use that provider ID and assert IsActive=true. + cfg, err := ddo.ValidateSP(ctx, 99999) + require.NoError(t, err) + require.NotNil(t, cfg) + t.Logf("ValidateSP(99999): active=%v, minPiece=%d, maxPiece=%d", + cfg.IsActive, cfg.MinPieceSize, cfg.MaxPieceSize) + + // TODO: Once a calibnet SP is registered in the DDO contract, add a + // test here with the real provider actor ID and assert: + // require.True(t, cfg.IsActive) + // require.Greater(t, cfg.MaxPieceSize, uint64(0)) +} + +// TODO: TestIntegration_DDOFullDealFlow +// This test requires a registered, active SP on calibnet. Once FF provides +// the SP and it's registered in the DDO contract: +// +// 1. Fork calibnet via Anvil +// 2. Fund a test wallet with FIL +// 3. Create a test preparation with a piece in the database +// 4. Create a DDO schedule pointing to the funded wallet and the SP +// 5. Run the deal pusher schedule +// 6. Verify allocation was created on-chain +// 7. Initialize DDOTrackingClient +// 8. Verify allocation tracking returns the correct status diff --git a/service/dealtracker/ddo_integration_test.go b/service/dealtracker/ddo_integration_test.go new file mode 100644 index 000000000..eca07aa4b --- /dev/null +++ b/service/dealtracker/ddo_integration_test.go @@ -0,0 +1,37 @@ +package dealtracker + +import ( + "context" + "testing" + "time" + + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/stretchr/testify/require" +) + +func startCalibnetFork(t *testing.T) *testutil.AnvilInstance { + t.Helper() + return testutil.StartAnvil(t, testutil.CalibnetRPC) +} + +// TestIntegration_DDOTrackingClientConnectivity verifies that the DDO tracking +// client can connect to a calibnet fork and query allocation info. +func TestIntegration_DDOTrackingClientConnectivity(t *testing.T) { + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL + + client, err := NewDDOTrackingClient(rpcURL, testutil.CalibnetDDOContract) + require.NoError(t, err) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Query a non-existent allocation — should return non-nil status, not error. + status, err := client.GetAllocationInfo(ctx, 0) + require.NoError(t, err) + require.NotNil(t, status) + require.False(t, status.Activated) + t.Logf("GetAllocationInfo(0): activated=%v, sectorNumber=%d", + status.Activated, status.SectorNumber) +} diff --git a/service/pdptracker/integration_test.go b/service/pdptracker/integration_test.go index e02b384d3..b8bf91f54 100644 --- a/service/pdptracker/integration_test.go +++ b/service/pdptracker/integration_test.go @@ -16,16 +16,14 @@ import ( "gorm.io/gorm" ) -const calibnetRPC = "https://api.calibration.node.glif.io/rpc/v1" - -func startCalibnetFork(t *testing.T) string { +func startCalibnetFork(t *testing.T) *testutil.AnvilInstance { t.Helper() - anvil := testutil.StartAnvil(t, calibnetRPC) - return anvil.RPCURL + return testutil.StartAnvil(t, testutil.CalibnetRPC) } func TestIntegration_NetworkDetection(t *testing.T) { - rpcURL := startCalibnetFork(t) + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -48,7 +46,7 @@ func TestIntegration_ShovelConfig(t *testing.T) { contractAddr := constants.GetPDPVerifierAddress(constants.NetworkCalibration) conf := buildShovelConfig( "postgres://localhost/test", - calibnetRPC, + testutil.CalibnetRPC, uint64(constants.ChainIDCalibration), contractAddr, 0, @@ -60,7 +58,8 @@ func TestIntegration_ShovelConfig(t *testing.T) { } func TestIntegration_ShovelIndexer(t *testing.T) { - rpcURL := startCalibnetFork(t) + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { if db.Dialector.Name() != "postgres" { @@ -83,7 +82,7 @@ func TestIntegration_ShovelIndexer(t *testing.T) { err = indexer.Start(indexCtx, exitErr) require.NoError(t, err) - time.Sleep(10 * time.Second) + anvil.MineBlock(t) var schemaExists bool err = db.Raw("SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = 'shovel')").Scan(&schemaExists).Error @@ -117,7 +116,8 @@ func TestIntegration_ShovelIndexer(t *testing.T) { } func TestIntegration_FullResync(t *testing.T) { - rpcURL := startCalibnetFork(t) + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { if db.Dialector.Name() != "postgres" { @@ -139,6 +139,9 @@ func TestIntegration_FullResync(t *testing.T) { exitErr := make(chan error, 1) require.NoError(t, indexer.Start(indexCtx, exitErr)) + // produce a block so shovel has something to index + anvil.MineBlock(t) + // poll until shovel has indexed at least one block require.Eventually(t, func() bool { var count int64 @@ -210,7 +213,8 @@ func TestIntegration_FullResync(t *testing.T) { } func TestIntegration_RPCClient(t *testing.T) { - rpcURL := startCalibnetFork(t) + anvil := startCalibnetFork(t) + rpcURL := anvil.RPCURL ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/util/testutil/anvil.go b/util/testutil/anvil.go index 5bc699e55..bf5c10cbf 100644 --- a/util/testutil/anvil.go +++ b/util/testutil/anvil.go @@ -4,12 +4,15 @@ import ( "context" "fmt" "net" + "net/http" "os" "os/exec" + "strings" "testing" "time" "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" ) type AnvilInstance struct { @@ -41,7 +44,6 @@ func StartAnvil(t *testing.T, upstreamRPC string) *AnvilInstance { cmd := exec.Command("anvil", "--fork-url", upstreamRPC, "--port", fmt.Sprintf("%d", port), - "--block-time", "1", "--silent", ) cmd.Stdout = os.Stderr @@ -61,6 +63,18 @@ func StartAnvil(t *testing.T, upstreamRPC string) *AnvilInstance { return inst } +// MineBlock forces anvil to produce a block. In automine mode (default), +// blocks are mined on tx submission, but consumers that only read (e.g. +// Shovel) need an explicit nudge to see chain progress. +func (a *AnvilInstance) MineBlock(t *testing.T) { + t.Helper() + resp, err := http.Post(a.RPCURL, "application/json", + strings.NewReader(`{"jsonrpc":"2.0","method":"evm_mine","params":[],"id":1}`)) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) +} + func freePort() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/util/testutil/calibnet.go b/util/testutil/calibnet.go new file mode 100644 index 000000000..c214aa496 --- /dev/null +++ b/util/testutil/calibnet.go @@ -0,0 +1,10 @@ +package testutil + +// calibnet constants shared by integration tests +const ( + CalibnetRPC = "https://api.calibration.node.glif.io/rpc/v1" + CalibnetChainID = 314159 + CalibnetDDOContract = "0x889fD50196BE300D06dc4b8F0F17fdB0af587095" + CalibnetPaymentsContract = "0x09a0fDc2723fAd1A7b8e3e00eE5DF73841df55a0" + CalibnetUSDFC = "0xb3042734b608a1B16e9e86B374A3f3e389B4cDf0" +) diff --git a/util/testutil/fund.go b/util/testutil/fund.go new file mode 100644 index 000000000..432b84e8f --- /dev/null +++ b/util/testutil/fund.go @@ -0,0 +1,97 @@ +package testutil + +import ( + "context" + "crypto/ecdsa" + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// anvilAccount0Key is the private key for Anvil's first pre-funded account +// (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 10000 ETH). +// This is a well-known deterministic test key — never use in production. +const anvilAccount0Key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +// AnvilFunderKey returns the parsed private key for Anvil's first pre-funded account. +func AnvilFunderKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.HexToECDSA(anvilAccount0Key) + require.NoError(t, err) + return key +} + +// FundEVMWallet sends ETH from Anvil's pre-funded account 0 to the given address. +// amount is in wei. Anvil automines the tx immediately. +func FundEVMWallet(t *testing.T, rpcURL string, to common.Address, amount *big.Int) common.Hash { + t.Helper() + + ctx := context.Background() + client, err := ethclient.DialContext(ctx, rpcURL) + require.NoError(t, err) + defer client.Close() + + funderKey := AnvilFunderKey(t) + funderAddr := crypto.PubkeyToAddress(funderKey.PublicKey) + + nonce, err := client.PendingNonceAt(ctx, funderAddr) + require.NoError(t, err) + + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + + gasPrice, err := client.SuggestGasPrice(ctx) + require.NoError(t, err) + + tx := types.NewTx(&types.LegacyTx{ + Nonce: nonce, + To: &to, + Value: amount, + Gas: 21000, + GasPrice: gasPrice, + }) + + signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), funderKey) + require.NoError(t, err) + + err = client.SendTransaction(ctx, signedTx) + require.NoError(t, err) + + // automine should be instant but some anvil versions need a moment + var receipt *types.Receipt + for range 20 { + receipt, err = client.TransactionReceipt(ctx, signedTx.Hash()) + if err == nil { + break + } + if !errors.Is(err, ethereum.NotFound) { + require.NoError(t, err) + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, err, "receipt not available after automine") + require.EqualValues(t, 1, receipt.Status, "funding transaction failed") + + return signedTx.Hash() +} + +// GenerateTestKey creates a fresh ECDSA key pair for testing and returns +// the private key and its corresponding EVM address. +func GenerateTestKey(t *testing.T) (*ecdsa.PrivateKey, common.Address) { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + addr := crypto.PubkeyToAddress(key.PublicKey) + return key, addr +} + +// OneEther is 1e18 wei, useful as a unit for test funding amounts. +var OneEther = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)