diff --git a/go.mod b/go.mod index 1714ffb53..85be59331 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,8 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.37.0 - github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b - github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b + github.com/trufnetwork/kwil-db v0.10.3-0.20251229074241-278c1930ac63 + github.com/trufnetwork/kwil-db/core v0.4.3-0.20251229074241-278c1930ac63 github.com/trufnetwork/sdk-go v0.3.2-0.20250630062504-841b40cdb709 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa diff --git a/go.sum b/go.sum index b1fccbd9f..4b13ec9bb 100644 --- a/go.sum +++ b/go.sum @@ -1214,8 +1214,12 @@ github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPD github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b h1:IAYxPaxlsn7+63cvZjrCcQKoWeKOXNPqp9NGifecKqw= github.com/trufnetwork/kwil-db v0.10.3-0.20251204134443-d43bfe5b400b/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= +github.com/trufnetwork/kwil-db v0.10.3-0.20251229074241-278c1930ac63 h1:MqZsMaNyX3sF/hSPF+8e7k8Rg+YJxU8jX8jHPr096x4= +github.com/trufnetwork/kwil-db v0.10.3-0.20251229074241-278c1930ac63/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b h1:CGtjMi1JqG/z1g+hnLVpYNS0yHz4Yif7Zflj6tzyTM8= github.com/trufnetwork/kwil-db/core v0.4.3-0.20251204134443-d43bfe5b400b/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20251229074241-278c1930ac63 h1:M3eB0d2Vq9uI1vrYWPUW8nFow6qrKpTvMLvdQAnHNwU= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20251229074241-278c1930ac63/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2 h1:DCq8MzbWH0wZmICNmMVsSzUHUPl+2vqRhluEABjxl88= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2/go.mod h1:Y0MJpPp9QXU5vC6Gpoilql2NkgmGNcbHm9HYC2v2N8s= github.com/trufnetwork/sdk-go v0.3.2-0.20250630062504-841b40cdb709 h1:d9EqPXIjbq/atzEncK5dM3Z9oStx1BxCGuL/sjefeCw= diff --git a/internal/migrations/erc20-bridge/000-extension.sql b/internal/migrations/erc20-bridge/000-extension.sql index f258d153b..ab7dcb39b 100644 --- a/internal/migrations/erc20-bridge/000-extension.sql +++ b/internal/migrations/erc20-bridge/000-extension.sql @@ -16,4 +16,11 @@ USE erc20 { chain: 'sepolia', escrow: '0x502430eD0BbE0f230215870c9C2853e126eE5Ae3' -} AS sepolia_bridge; \ No newline at end of file +} AS sepolia_bridge; + +-- The following is for test environments where the hoodi bridge is used. Please comment for production and use the above. +USE erc20 { + chain: 'hoodi', + escrow: '0x878d6aaeb6e746033f50b8dc268d54b4631554e7', + distribution_period: '30m' +} AS hoodi_bridge; \ No newline at end of file diff --git a/internal/migrations/erc20-bridge/001-actions.sql b/internal/migrations/erc20-bridge/001-actions.sql index 81d494000..1250fd91a 100644 --- a/internal/migrations/erc20-bridge/001-actions.sql +++ b/internal/migrations/erc20-bridge/001-actions.sql @@ -1,4 +1,62 @@ --- TESTNET +-- HOODI TESTNET +CREATE OR REPLACE ACTION hoodi_get_erc20_bridge_info() +PUBLIC VIEW RETURNS ( + chain TEXT, + escrow TEXT, + epoch_period TEXT, + erc20 TEXT, + decimals INT, + balance NUMERIC(78, 0), + synced BOOLEAN, + synced_at INT8, + enabled BOOLEAN +) { + FOR $row IN hoodi_bridge.info() { + RETURN $row.chain, $row.escrow, $row.epoch_period, $row.erc20, $row.decimals, $row.balance, $row.synced, $row.synced_at, $row.enabled; + } +}; + +CREATE OR REPLACE ACTION hoodi_wallet_balance($wallet_address TEXT) PUBLIC VIEW RETURNS (balance NUMERIC(78, 0)) { + $balance := hoodi_bridge.balance($wallet_address); + RETURN $balance; +}; + +CREATE OR REPLACE ACTION hoodi_bridge_tokens($recipient TEXT DEFAULT NULL, $amount TEXT) PUBLIC { + -- ===== FEE COLLECTION (NO EXEMPTION - USER-FACING OPERATION) ===== + $withdrawal_fee := '40000000000000000000'::NUMERIC(78, 0); -- 40 TRUF with 18 decimals + $withdrawal_amount := $amount::NUMERIC(78, 0); + $total_required := $withdrawal_amount + $withdrawal_fee; + + $caller_balance := COALESCE(hoodi_bridge.balance(@caller), 0::NUMERIC(78, 0)); + + IF $caller_balance < $total_required { + ERROR('Insufficient balance for withdrawal. Required: ' || + ($total_required / '1000000000000000000'::NUMERIC(78, 0))::TEXT || + ' TRUF (' || $withdrawal_amount::TEXT || ' wei withdrawal + ' || + ($withdrawal_fee / '1000000000000000000'::NUMERIC(78, 0))::TEXT || ' TRUF fee)'); + } + + IF @leader_sender IS NULL { + ERROR('Leader address not available for fee transfer'); + } + $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; + hoodi_bridge.transfer($leader_hex, $withdrawal_fee); + -- ===== END FEE COLLECTION ===== + + $bridge_recipient TEXT := LOWER(COALESCE($recipient, @caller)); + + -- Execute withdrawal using the bridge extension + hoodi_bridge.bridge($bridge_recipient, $withdrawal_amount); + + record_transaction_event( + 5, + $withdrawal_fee, + '0x' || $leader_hex, + NULL + ); +}; + +-- SEPOLIA TESTNET (kept for reference) CREATE OR REPLACE ACTION sepolia_get_erc20_bridge_info() PUBLIC VIEW RETURNS ( chain TEXT, diff --git a/tests/extensions/erc20/erc20_bridge_hoodi_test.go b/tests/extensions/erc20/erc20_bridge_hoodi_test.go new file mode 100644 index 000000000..e1556e97c --- /dev/null +++ b/tests/extensions/erc20/erc20_bridge_hoodi_test.go @@ -0,0 +1,424 @@ +//go:build kwiltest + +package tests + +import ( + "context" + "strings" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + testerc20 "github.com/trufnetwork/node/tests/streams/utils/erc20" + + "github.com/trufnetwork/kwil-db/core/types" + erc20shim "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" +) + +// Hoodi Testnet Configuration +const ( + // Hoodi chain identifier (must match chains.go registration) + TestChainHoodi = "hoodi" + + // Hoodi bridge extension alias for testing + TestExtensionAliasHoodi = "hoodi_bridge_test" + + // TEST-ONLY addresses (not real Hoodi contracts!) + // Using fake addresses to avoid conflicts with migration-created instances + // Real Hoodi addresses are in migrations: 0x878d6aaeb6e746033f50b8dc268d54b4631554e7 + TestEscrowHoodi = "0x3333333333333333333333333333333333333333" // Fake test escrow + TestERC20Hoodi = "0x4444444444444444444444444444444444444444" // Fake test ERC20 + + // Test users (same as Sepolia for consistency) + TestUserHoodiA = "0xabc0000000000000000000000000000000000001" + TestUserHoodiB = "0xabc0000000000000000000000000000000000002" + TestUserHoodiC = "0xabc0000000000000000000000000000000000003" + + // Test amounts (18 decimals for TRUF token) + TestAmountHoodi1 = "1000000000000000000" // 1.0 TRUF + TestAmountHoodi2 = "2000000000000000000" // 2.0 TRUF +) + +// TestERC20BridgeEndToEndHoodi tests the complete bridge flow on Hoodi testnet. +// +// Test flow: +// 1) Initialize Hoodi bridge instance with test alias +// 2) Inject deposit to give user a balance (simulates Hoodi → Kwil deposit) +// 3) Call bridge action to lock tokens and create withdrawal request +// 4) Finalize and confirm epoch (makes withdrawal ready) +// 5) Verify user has wallet rewards (withdrawal proof available) +// +// This validates: +// - Hoodi chain is recognized by kwil-db +// - Deposit listener would work (simulated via injection) +// - Withdrawal flow works end-to-end +// - Epoch management functions correctly +func TestERC20BridgeEndToEndHoodi(t *testing.T) { + seedAndRun(t, "erc20_bridge_end_to_end_hoodi", func(ctx context.Context, platform *kwilTesting.Platform) error { + // Enable Hoodi bridge instance with alias for end-to-end test + // Args: (ctx, platform, chain, escrow, erc20, decimals, distribution_period_blocks, alias) + require.NoError(t, erc20shim.ForTestingSeedAndActivateInstance( + ctx, platform, + TestChainHoodi, // Hoodi chain + TestEscrowHoodi, // Hoodi bridge proxy address + TestERC20Hoodi, // TRUF token on Hoodi + 18, // Token decimals + 1, // Distribution period (1 block for testing) + TestExtensionAliasHoodi, // Test alias + )) + + // Sanity check: ensure the instance reports synced and enabled via info() + engineCtx := engCtx(ctx, platform, "0x0000000000000000000000000000000000000000", 1, false) + var syncedResult, enabledResult bool + resInfo, errInfo := platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "info", []any{}, func(row *common.Row) error { + if len(row.Values) < 9 { + return nil + } + syncedResult = row.Values[6].(bool) // synced column + enabledResult = row.Values[8].(bool) // enabled column + return nil + }) + require.NoError(t, errInfo) + if resInfo != nil && resInfo.Error != nil { + return resInfo.Error + } + require.True(t, syncedResult, "Hoodi instance should be synced before bridge") + require.True(t, enabledResult, "Hoodi instance should be enabled before bridge") + + // Step 1: Inject deposit to give user a balance + // This simulates a user depositing TRUF on Hoodi testnet + // In production, the deposit listener would detect this on-chain + err := testerc20.InjectERC20Transfer( + ctx, platform, + TestChainHoodi, // Hoodi chain + TestEscrowHoodi, // Bridge contract + TestERC20Hoodi, // TRUF token + TestUserHoodiA, // Depositor + TestUserHoodiA, // Recipient (same user) + TestAmountHoodi1, // 1.0 TRUF + 10, // Point counter + nil, // Previous point (nil for first deposit) + ) + require.NoError(t, err) + + // Verify user has the balance + balance, err := testerc20.GetUserBalance(ctx, platform, TestExtensionAliasHoodi, TestUserHoodiA) + require.NoError(t, err) + require.Equal(t, TestAmountHoodi1, balance, "user should have deposit amount (1.0 TRUF)") + + // Step 2: Call bridge action as user to lock tokens and create withdrawal + // This simulates user requesting withdrawal from Kwil → Hoodi + engineCtx = engCtx(ctx, platform, TestUserHoodiA, 2, false) + + amtDec, err := types.ParseDecimalExplicit(TestAmountHoodi1, 78, 0) + require.NoError(t, err) + + r, err := platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "bridge", []any{TestUserHoodiA, amtDec}, func(row *common.Row) error { + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + + // Verify a pending reward exists after bridge for this instance and user + instanceID := erc20shim.ForTestingGetInstanceID(TestChainHoodi, TestEscrowHoodi) + preQ := ` + {kwil_erc20_meta}SELECT count(*) FROM epoch_rewards r + JOIN epochs e ON e.id = r.epoch_id + WHERE e.instance_id = $id AND r.recipient = $user` + var preRows int + err = platform.Engine.ExecuteWithoutEngineCtx(ctx, platform.DB, preQ, map[string]any{ + "id": instanceID, + "user": ethcommon.HexToAddress(TestUserHoodiA).Bytes(), + }, func(row *common.Row) error { + if len(row.Values) != 1 { + return nil + } + preRows = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + require.Greater(t, preRows, 0, "expected pending epoch reward before finalize for user in Hoodi instance") + + // Step 3-4: Deterministically finalize current epoch and confirm + // This makes the withdrawal "ready" for claiming on Hoodi + var bh [32]byte // Block hash (deterministic for testing) + require.NoError(t, erc20shim.ForTestingFinalizeAndConfirmCurrentEpoch( + ctx, platform, + TestChainHoodi, // Hoodi chain + TestEscrowHoodi, // Bridge contract + 11, // Point counter + bh, // Block hash + )) + + // Diagnostics: fetch instance id via alias to verify setup + engineCtx = engCtx(ctx, platform, "0x0000000000000000000000000000000000000000", 3, false) + var confirmedInstanceID *types.UUID + resID, errID := platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "id", []any{}, func(row *common.Row) error { + if len(row.Values) != 1 { + return nil + } + confirmedInstanceID = row.Values[0].(*types.UUID) + return nil + }) + require.NoError(t, errID) + if resID != nil && resID.Error != nil { + return resID.Error + } + require.NotNil(t, confirmedInstanceID, "Hoodi instance id should not be nil") + + // Step 5: Query wallet rewards (confirmed only) to verify bridge flow worked + // This confirms the withdrawal proof is available via GetWithdrawalProof + rewardRows := 0 + r, err = platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "list_wallet_rewards", []any{TestUserHoodiA, false}, func(row *common.Row) error { + rewardRows++ + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + require.Greater(t, rewardRows, 0, "user should have at least one wallet reward after Hoodi bridge flow") + + return nil + }) +} + +// TestERC20BridgeCustomRecipientHoodi tests withdrawal to a different recipient. +// +// Test flow: +// 1) User A deposits TRUF on Hoodi +// 2) User A requests withdrawal but specifies User B as recipient +// 3) Epoch finalizes +// 4) Verify User B (not User A) has the withdrawal reward +// +// This validates: +// - Custom recipient feature works on Hoodi +// - Withdrawal proof would be generated for User B's address +func TestERC20BridgeCustomRecipientHoodi(t *testing.T) { + seedAndRun(t, "erc20_bridge_custom_recipient_hoodi", func(ctx context.Context, platform *kwilTesting.Platform) error { + // Enable Hoodi bridge instance with alias for custom recipient test + require.NoError(t, erc20shim.ForTestingSeedAndActivateInstance( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + TestERC20Hoodi, + 18, + 1, + TestExtensionAliasHoodi, + )) + + // Give user A balance to bridge (1.0 TRUF) + require.NoError(t, testerc20.InjectERC20Transfer( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + TestERC20Hoodi, + TestUserHoodiA, // Depositor + TestUserHoodiA, // Recipient (same) + TestAmountHoodi1, + 10, + nil, + )) + + // User A calls bridge action but specifies User B as recipient + engineCtx := engCtx(ctx, platform, TestUserHoodiA, 2, false) + amtDec, err := types.ParseDecimalExplicit(TestAmountHoodi1, 78, 0) + require.NoError(t, err) + + r, err := platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "bridge", []any{TestUserHoodiB, amtDec}, func(row *common.Row) error { + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + + // Finalize and confirm epoch + var bh [32]byte + require.NoError(t, erc20shim.ForTestingFinalizeAndConfirmCurrentEpoch( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + 11, + bh, + )) + + // Verify User B (custom recipient) has the reward, not User A + instanceID := erc20shim.ForTestingGetInstanceID(TestChainHoodi, TestEscrowHoodi) + customRewardQuery := ` + {kwil_erc20_meta}SELECT count(*) FROM epoch_rewards r + JOIN epochs e ON e.id = r.epoch_id + WHERE e.instance_id = $id AND r.recipient = $user` + var rows int + err = platform.Engine.ExecuteWithoutEngineCtx(ctx, platform.DB, customRewardQuery, map[string]any{ + "id": instanceID, + "user": ethcommon.HexToAddress(TestUserHoodiB).Bytes(), + }, func(row *common.Row) error { + if len(row.Values) != 1 { + return nil + } + rows = int(row.Values[0].(int64)) + return nil + }) + require.NoError(t, err) + require.Greater(t, rows, 0, "expected pending epoch reward for custom recipient (User B) on Hoodi") + + // Query wallet rewards for User B and verify recipient address + engineCtx = engCtx(ctx, platform, TestUserHoodiA, 3, false) + rewardRows := 0 + r, err = platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "list_wallet_rewards", []any{TestUserHoodiB, false}, func(row *common.Row) error { + rewardRows++ + // Verify the recipient column contains User B's address + recipientValue, ok := row.Values[4].(string) + require.True(t, ok, "recipient should be string") + require.True(t, strings.EqualFold(TestUserHoodiB, recipientValue), "recipient should be User B") + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + require.Greater(t, rewardRows, 0, "user B should have at least one wallet reward after custom bridge on Hoodi") + + return nil + }) +} + +// TestERC20BridgeHoodiMultipleDeposits tests multiple deposits and withdrawals. +// +// Test flow: +// 1) User A deposits 1.0 TRUF +// 2) User B deposits 2.0 TRUF +// 3) User A withdraws 0.5 TRUF +// 4) User B withdraws 1.0 TRUF +// 5) Finalize epoch +// 6) Verify both users have correct wallet rewards +// +// This validates: +// - Multiple users can use the same bridge instance +// - Balances are tracked correctly per user +// - Partial withdrawals work +func TestERC20BridgeHoodiMultipleDeposits(t *testing.T) { + seedAndRun(t, "erc20_bridge_hoodi_multiple_deposits", func(ctx context.Context, platform *kwilTesting.Platform) error { + // Enable Hoodi bridge instance + require.NoError(t, erc20shim.ForTestingSeedAndActivateInstance( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + TestERC20Hoodi, + 18, + 1, + TestExtensionAliasHoodi, + )) + + // Deposit for User A (1.0 TRUF) + // First deposit has prev=nil + pointA := int64(10) + require.NoError(t, testerc20.InjectERC20Transfer( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + TestERC20Hoodi, + TestUserHoodiA, + TestUserHoodiA, + TestAmountHoodi1, // 1.0 TRUF + pointA, + nil, // First deposit: prev=nil + )) + + // Deposit for User B (2.0 TRUF) + // MUST chain to previous deposit for ordered-sync processing + pointB := int64(11) + prevPoint := pointA // Chain to User A's deposit + require.NoError(t, testerc20.InjectERC20Transfer( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + TestERC20Hoodi, + TestUserHoodiB, + TestUserHoodiB, + TestAmountHoodi2, // 2.0 TRUF + pointB, + &prevPoint, // Chain to previous deposit + )) + + // Verify balances + balanceA, err := testerc20.GetUserBalance(ctx, platform, TestExtensionAliasHoodi, TestUserHoodiA) + require.NoError(t, err) + require.Equal(t, TestAmountHoodi1, balanceA, "User A should have 1.0 TRUF") + + balanceB, err := testerc20.GetUserBalance(ctx, platform, TestExtensionAliasHoodi, TestUserHoodiB) + require.NoError(t, err) + require.Equal(t, TestAmountHoodi2, balanceB, "User B should have 2.0 TRUF") + + // User A withdraws 0.5 TRUF + halfAmount := "500000000000000000" // 0.5 TRUF + engineCtxA := engCtx(ctx, platform, TestUserHoodiA, 2, false) + amtDecHalf, err := types.ParseDecimalExplicit(halfAmount, 78, 0) + require.NoError(t, err) + + r, err := platform.Engine.Call(engineCtxA, platform.DB, TestExtensionAliasHoodi, "bridge", []any{TestUserHoodiA, amtDecHalf}, func(row *common.Row) error { + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + + // User B withdraws 1.0 TRUF + engineCtxB := engCtx(ctx, platform, TestUserHoodiB, 3, false) + amtDecOne, err := types.ParseDecimalExplicit(TestAmountHoodi1, 78, 0) + require.NoError(t, err) + + r, err = platform.Engine.Call(engineCtxB, platform.DB, TestExtensionAliasHoodi, "bridge", []any{TestUserHoodiB, amtDecOne}, func(row *common.Row) error { + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + + // Finalize and confirm epoch + var bh [32]byte + require.NoError(t, erc20shim.ForTestingFinalizeAndConfirmCurrentEpoch( + ctx, platform, + TestChainHoodi, + TestEscrowHoodi, + 12, + bh, + )) + + // Verify both users have wallet rewards + engineCtx := engCtx(ctx, platform, "0x0000000000000000000000000000000000000000", 4, false) + + // Check User A rewards (should have 1 reward for 0.5 TRUF) + rewardRowsA := 0 + r, err = platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "list_wallet_rewards", []any{TestUserHoodiA, false}, func(row *common.Row) error { + rewardRowsA++ + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + require.Greater(t, rewardRowsA, 0, "User A should have wallet rewards") + + // Check User B rewards (should have 1 reward for 1.0 TRUF) + rewardRowsB := 0 + r, err = platform.Engine.Call(engineCtx, platform.DB, TestExtensionAliasHoodi, "list_wallet_rewards", []any{TestUserHoodiB, false}, func(row *common.Row) error { + rewardRowsB++ + return nil + }) + require.NoError(t, err) + if r != nil && r.Error != nil { + return r.Error + } + require.Greater(t, rewardRowsB, 0, "User B should have wallet rewards") + + return nil + }) +} diff --git a/tests/streams/hoodi_withdrawal_fee_test.go b/tests/streams/hoodi_withdrawal_fee_test.go new file mode 100644 index 000000000..79295bd58 --- /dev/null +++ b/tests/streams/hoodi_withdrawal_fee_test.go @@ -0,0 +1,390 @@ +//go:build kwiltest + +package tests + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/core/crypto" + coreauth "github.com/trufnetwork/kwil-db/core/crypto/auth" + erc20shim "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/internal/migrations" + testutils "github.com/trufnetwork/node/tests/streams/utils" + testerc20 "github.com/trufnetwork/node/tests/streams/utils/erc20" + "github.com/trufnetwork/sdk-go/core/util" +) + +// Test constants for Hoodi withdrawal fees +const ( + testHoodiChain = "hoodi" + testHoodiEscrow = "0x878d6aaeb6e746033f50b8dc268d54b4631554e7" // Real Hoodi bridge proxy + testHoodiERC20 = "0x263ce78fef26600e4e428cebc91c2a52484b4fbf" // Real TRUF token on Hoodi + testHoodiExtensionName = "hoodi_bridge" // Extension name from migrations + hoodiWithdrawalFee = "40000000000000000000" // 40 TRUF with 18 decimals +) + +var ( + fortyTRUFHoodiFee = mustParseHoodiBigInt(hoodiWithdrawalFee) // 40 TRUF as big.Int + hoodiPointCounter int64 = 6000 // Start from 6000, increment for each balance injection + hoodiPrevPoint *int64 // Track previous point for deposit chaining +) + +func mustParseHoodiBigInt(s string) *big.Int { + val := new(big.Int) + _, ok := val.SetString(s, 10) + if !ok { + panic(fmt.Sprintf("failed to parse big.Int: %q", s)) + } + return val +} + +// TestHoodiWithdrawalFees is the main test suite for Hoodi bridge withdrawal fees +// This test validates the hoodi_bridge_tokens action defined in 001-actions.sql +func TestHoodiWithdrawalFees(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "HOODI_WITHDRAWAL_FEE01_WithdrawalFees", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + setupHoodiWithdrawalTestEnvironment(t), + testHoodiWithdrawalPaysFee(t), + testHoodiWithdrawalInsufficientBalance(t), + testHoodiWithdrawalLeaderReceivesFees(t), + testHoodiWithdrawalFeeRecordedInLedger(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// setupHoodiWithdrawalTestEnvironment sets up the Hoodi bridge test environment +func setupHoodiWithdrawalTestEnvironment(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Reset the previous point tracker for deposit chaining + hoodiPrevPoint = nil + + // Use the system admin address (derived from private key 0x00...01) + systemAdmin := util.Unsafe_NewEthereumAddressFromString("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf") + platform.Deployer = systemAdmin.Bytes() + + // Sync and initialize Hoodi bridge extension + // The hoodi_bridge instance is created by migrations (000-extension.sql) + // ForTestingForceSyncInstance ensures it's synced in DB + _, err := erc20shim.ForTestingForceSyncInstance( + ctx, + platform, + testHoodiChain, + testHoodiEscrow, + testHoodiERC20, + 18, // TRUF decimals + ) + if err != nil { + return fmt.Errorf("failed to sync Hoodi bridge instance: %w", err) + } + + // Initialize extension to load instances into singleton + // This is CRITICAL - without this, lock()/unlock() will fail with "not synced" + err = erc20shim.ForTestingInitializeExtension(ctx, platform) + if err != nil { + return fmt.Errorf("failed to initialize extension: %w", err) + } + + return nil + } +} + +// Test 1: hoodi_bridge_tokens pays 40 TRUF fee +func testHoodiWithdrawalPaysFee(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Re-initialize extension in this test (singleton might have been reset) + err := erc20shim.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err, "failed to re-initialize extension") + + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0xc111111111111111111111111111111111111111") + userAddr := &userAddrVal + + // Give user 100 TRUF + err = giveHoodiBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + require.NoError(t, err, "failed to give balance") + + // Get initial balance + initialBalance, err := getHoodiBalance(ctx, platform, userAddr.Address()) + require.NoError(t, err, "failed to get initial balance") + t.Logf("DEBUG: User balance after deposit: %s", initialBalance.String()) + require.Equal(t, "100000000000000000000", initialBalance.String(), "initial balance should be 100 TRUF (got %s)", initialBalance.String()) + + // Generate leader for fee recipient + _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) + require.NoError(t, err, "failed to generate leader key") + pub := pubGeneric.(*crypto.Secp256k1PublicKey) + + // Withdraw 10 TRUF (should deduct 50 TRUF total: 10 withdrawal + 40 fee) + withdrawAmount := "10000000000000000000" // 10 TRUF + err = executeHoodiWithdrawalWithLeader(ctx, platform, userAddr, pub, userAddr.Address(), withdrawAmount) + require.NoError(t, err, "hoodi_bridge_tokens should succeed") + + // Verify balance decreased by 50 TRUF (10 TRUF withdrawal + 40 TRUF fee) + finalBalance, err := getHoodiBalance(ctx, platform, userAddr.Address()) + require.NoError(t, err, "failed to get final balance") + + withdrawAmountBig, _ := new(big.Int).SetString(withdrawAmount, 10) + expectedDeduction := new(big.Int).Add(withdrawAmountBig, fortyTRUFHoodiFee) + expectedBalance := new(big.Int).Sub(initialBalance, expectedDeduction) + require.Equal(t, 0, expectedBalance.Cmp(finalBalance), + "Balance should decrease by 50 TRUF (10 withdrawal + 40 fee), expected %s but got %s", + expectedBalance, finalBalance) + + t.Logf("✅ hoodi_bridge_tokens correctly deducted 50 TRUF (10 withdrawal + 40 fee)") + return nil + } +} + +// Test 2: Insufficient balance for withdrawal + fee fails +func testHoodiWithdrawalInsufficientBalance(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Re-initialize extension in this test (singleton might have been reset) + err := erc20shim.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err, "failed to re-initialize extension") + + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0xc222222222222222222222222222222222222222") + userAddr := &userAddrVal + + // Give user only 30 TRUF (insufficient for 10 TRUF withdrawal + 40 TRUF fee = 50 TRUF needed) + err = giveHoodiBalance(ctx, platform, userAddr.Address(), "30000000000000000000") + require.NoError(t, err, "failed to give balance") + + // Generate leader for fee recipient + _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) + require.NoError(t, err, "failed to generate leader key") + pub := pubGeneric.(*crypto.Secp256k1PublicKey) + + // Try to withdraw 10 TRUF (should fail - needs 50 TRUF total) + withdrawAmount := "10000000000000000000" // 10 TRUF + err = executeHoodiWithdrawalWithLeader(ctx, platform, userAddr, pub, userAddr.Address(), withdrawAmount) + require.Error(t, err, "hoodi_bridge_tokens should fail with insufficient balance") + require.Contains(t, err.Error(), "Insufficient balance for withdrawal", + "error should mention insufficient balance, got: %v", err) + + t.Logf("✅ hoodi_bridge_tokens correctly rejects insufficient balance (30 TRUF < 50 TRUF needed)") + return nil + } +} + +// Test 3: Leader receives 40 TRUF fee +func testHoodiWithdrawalLeaderReceivesFees(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Re-initialize extension in this test (singleton might have been reset) + err := erc20shim.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err, "failed to re-initialize extension") + + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0xc333333333333333333333333333333333333333") + userAddr := &userAddrVal + + // Give user 100 TRUF + // Reset deposit chain for this test to avoid cross-test interference + oldPrevPoint := hoodiPrevPoint + hoodiPrevPoint = nil + t.Logf("DEBUG test3: Reset prev point from %v to nil", oldPrevPoint) + err = giveHoodiBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + require.NoError(t, err, "failed to give balance") + + // Check balance + bal, err := getHoodiBalance(ctx, platform, userAddr.Address()) + require.NoError(t, err, "failed to get balance") + t.Logf("DEBUG test3: User balance after deposit: %s (expected 100 TRUF)", bal.String()) + + // Generate leader keys + _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) + require.NoError(t, err, "failed to generate leader key") + pub := pubGeneric.(*crypto.Secp256k1PublicKey) + + // Get leader address + leaderSigner := crypto.EthereumAddressFromPubKey(pub) + leaderAddr := fmt.Sprintf("0x%x", leaderSigner) + + // Get initial leader balance (should be 0) + initialLeaderBalance, err := getHoodiBalance(ctx, platform, leaderAddr) + require.NoError(t, err, "failed to get initial leader balance") + + // Withdraw 10 TRUF with specific leader + withdrawAmount := "10000000000000000000" // 10 TRUF + err = executeHoodiWithdrawalWithLeader(ctx, platform, userAddr, pub, userAddr.Address(), withdrawAmount) + require.NoError(t, err, "hoodi_bridge_tokens with leader should succeed") + + // Verify leader balance increased by 40 TRUF + finalLeaderBalance, err := getHoodiBalance(ctx, platform, leaderAddr) + require.NoError(t, err, "failed to get final leader balance") + + expectedLeaderBalance := new(big.Int).Add(initialLeaderBalance, fortyTRUFHoodiFee) + require.Equal(t, 0, expectedLeaderBalance.Cmp(finalLeaderBalance), + "Leader should receive 40 TRUF fee, expected %s but got %s", + expectedLeaderBalance, finalLeaderBalance) + + t.Logf("✅ Leader correctly received 40 TRUF fee (balance: %s → %s)", + initialLeaderBalance, finalLeaderBalance) + return nil + } +} + +// Test 4: Withdrawal fee is recorded in transaction ledger +func testHoodiWithdrawalFeeRecordedInLedger(t *testing.T) func(ctx context.Context, platform *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + // Re-initialize extension in this test + err := erc20shim.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err, "failed to re-initialize extension") + + userAddrVal := util.Unsafe_NewEthereumAddressFromString("0xc444444444444444444444444444444444444444") + userAddr := &userAddrVal + + // Give user 100 TRUF + // Reset deposit chain for test independence + hoodiPrevPoint = nil + err = giveHoodiBalance(ctx, platform, userAddr.Address(), "100000000000000000000") + require.NoError(t, err, "failed to give balance") + + // Generate leader keys + _, pubGeneric, err := crypto.GenerateSecp256k1Key(nil) + require.NoError(t, err, "failed to generate leader key") + pub := pubGeneric.(*crypto.Secp256k1PublicKey) + leaderSigner := crypto.EthereumAddressFromPubKey(pub) + leaderAddr := fmt.Sprintf("0x%x", leaderSigner) + + // Withdraw 10 TRUF + withdrawAmount := "10000000000000000000" // 10 TRUF + err = executeHoodiWithdrawalWithLeader(ctx, platform, userAddr, pub, userAddr.Address(), withdrawAmount) + require.NoError(t, err, "hoodi_bridge_tokens should succeed") + + // Query transaction_events table to verify fee was recorded + // The hoodi_bridge_tokens action calls record_transaction_event(5, $withdrawal_fee, leader, NULL) + // Table schema: method_id (INT), fee_amount (NUMERIC), fee_recipient (TEXT) + query := `SELECT method_id, fee_amount::TEXT, fee_recipient FROM transaction_events + WHERE method_id = 5 AND fee_recipient IS NOT NULL + ORDER BY block_height DESC LIMIT 1` + + var methodID int + var feeAmount string + var feeRecipient string + var found bool + + err = platform.Engine.ExecuteWithoutEngineCtx(ctx, platform.DB, query, map[string]any{}, func(row *common.Row) error { + found = true + if len(row.Values) >= 3 { + // Safe type assertions with comma-ok idiom + if val, ok := row.Values[0].(int64); ok { + methodID = int(val) + } else { + return fmt.Errorf("expected int64 for method_id, got %T", row.Values[0]) + } + + if val, ok := row.Values[1].(string); ok { + feeAmount = val + } else { + return fmt.Errorf("expected string for fee_amount, got %T", row.Values[1]) + } + + if val, ok := row.Values[2].(string); ok { + feeRecipient = val + } else { + return fmt.Errorf("expected string for fee_recipient, got %T", row.Values[2]) + } + } + return nil + }) + require.NoError(t, err, "failed to query transaction_events") + require.True(t, found, "withdrawal fee event should be recorded in transaction_events") + + // Verify event details + require.Equal(t, 5, methodID, "method_id should be 5 (withdrawal fee)") + require.Equal(t, hoodiWithdrawalFee, feeAmount, "fee_amount should be 40 TRUF fee") + require.Equal(t, leaderAddr, feeRecipient, "fee_recipient should be leader address") + + t.Logf("✅ Withdrawal fee correctly recorded in transaction_events (event_type=5, amount=40 TRUF, recipient=%s)", + leaderAddr) + return nil + } +} + +// ===== HELPER FUNCTIONS ===== + +// giveHoodiBalance credits TRUF balance to a wallet using ERC20 inject with deposit chaining +func giveHoodiBalance(ctx context.Context, platform *kwilTesting.Platform, wallet string, amountStr string) error { + hoodiPointCounter++ + currentPoint := hoodiPointCounter + + err := testerc20.InjectERC20Transfer( + ctx, + platform, + testHoodiChain, + testHoodiEscrow, + testHoodiERC20, + wallet, + wallet, + amountStr, + currentPoint, + hoodiPrevPoint, // Chain to previous deposit (nil for first) + ) + + if err == nil { + // Update previous point for next deposit + p := currentPoint + hoodiPrevPoint = &p + } + + return err +} + +// getHoodiBalance retrieves the TRUF balance for a wallet +func getHoodiBalance(ctx context.Context, platform *kwilTesting.Platform, wallet string) (*big.Int, error) { + balanceStr, err := testerc20.GetUserBalance(ctx, platform, testHoodiExtensionName, wallet) + if err != nil { + return nil, err + } + + balance := new(big.Int) + if _, ok := balance.SetString(balanceStr, 10); !ok { + return nil, fmt.Errorf("invalid balance string: %s", balanceStr) + } + + return balance, nil +} + +// callHoodiWithdrawalAction calls the hoodi_bridge_tokens action +func callHoodiWithdrawalAction(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, leaderPub *crypto.Secp256k1PublicKey, recipient string, amount string) error { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{ + Height: 2, // Use height 2 to ensure it's after initial setup + Proposer: leaderPub, + }, + Signer: signer.Bytes(), + Caller: signer.Address(), + TxID: platform.Txid(), + Authenticator: coreauth.EthPersonalSignAuth, + } + engineCtx := &common.EngineContext{TxContext: tx} + + // Call hoodi_bridge_tokens action (defined in 001-actions.sql) + res, err := platform.Engine.Call( + engineCtx, + platform.DB, + "", + "hoodi_bridge_tokens", // Hoodi-specific action with 40 TRUF fee + []any{recipient, amount}, + func(row *common.Row) error { return nil }, + ) + if err != nil { + return err + } + if res != nil && res.Error != nil { + return res.Error + } + return nil +} + +// executeHoodiWithdrawalWithLeader executes a withdrawal with a specific leader (for testing fee recipient) +func executeHoodiWithdrawalWithLeader(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, leaderPub *crypto.Secp256k1PublicKey, recipient string, amount string) error { + return callHoodiWithdrawalAction(ctx, platform, signer, leaderPub, recipient, amount) +}