diff --git a/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql b/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql index 5e0d46b8..d405ddfb 100644 --- a/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql +++ b/internal/migrations/erc20-bridge/002-public-transfer-actions.prod.sql @@ -9,7 +9,7 @@ -- production bridges: -- -- eth_truf_transfer — TRUF token p2p transfer + 1 TRUF fee (18 decimals) --- eth_usdc_transfer — USDC token p2p transfer + 1 USDC fee (6 decimals) +-- eth_usdc_transfer — USDC token p2p transfer + 0.01 USDC fee (6 decimals) -- -- Both follow the same pattern as the original ethereum_transfer: -- fee is paid in the SAME bridge as the transfer (not always in TRUF). @@ -65,7 +65,7 @@ CREATE OR REPLACE ACTION eth_truf_transfer($to_address TEXT, $amount TEXT) PUBLI -- The fee is paid in the bridge that moved, not always in $TRUF, and -- transaction_events has no token column. Naming the bridge here is what lets - -- a reader tell this row's 1 TRUF from eth_usdc_transfer's 1 USDC. + -- a reader tell this row's 1 TRUF from eth_usdc_transfer's cent of USDC. record_transaction_event( 4, $fee, @@ -76,7 +76,7 @@ CREATE OR REPLACE ACTION eth_truf_transfer($to_address TEXT, $amount TEXT) PUBLI -- USDC p2p transfer --- Fee: 1 USDC (10^6 wei, since USDC has 6 decimals) +-- Fee: 0.01 USDC (10^4 token base units, since USDC has 6 decimals) CREATE OR REPLACE ACTION eth_usdc_transfer($to_address TEXT, $amount TEXT) PUBLIC { $recipient_lower TEXT := LOWER($to_address); @@ -91,7 +91,10 @@ CREATE OR REPLACE ACTION eth_usdc_transfer($to_address TEXT, $amount TEXT) PUBLI } -- Fee - $fee := 1000000::NUMERIC(78, 0); -- 1 USDC with 6 decimals + -- One cent, not one dollar. USDC moves in small amounts and a $1 fee priced + -- most of those transfers out, while a cent still makes spamming the action + -- expensive enough to be worth nobody's while. + $fee := 10000::NUMERIC(78, 0); -- 0.01 USDC with 6 decimals $caller_balance := COALESCE(eth_usdc.balance(@caller), 0::NUMERIC(78, 0)); IF @leader_sender IS NULL { @@ -100,7 +103,7 @@ CREATE OR REPLACE ACTION eth_usdc_transfer($to_address TEXT, $amount TEXT) PUBLI $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; IF ($caller_balance < ($amount::NUMERIC(78, 0) + $fee)) { - ERROR('Insufficient balance for transfer. Requires an extra 1 USDC fee on top of the transfer amount'); + ERROR('Insufficient balance for transfer. Requires an extra 0.01 USDC fee on top of the transfer amount'); } eth_usdc.transfer($leader_hex, $fee); diff --git a/internal/migrations/erc20-bridge/002-public-transfer-actions.sql b/internal/migrations/erc20-bridge/002-public-transfer-actions.sql index a059982f..aa95c1c8 100644 --- a/internal/migrations/erc20-bridge/002-public-transfer-actions.sql +++ b/internal/migrations/erc20-bridge/002-public-transfer-actions.sql @@ -139,7 +139,13 @@ CREATE OR REPLACE ACTION hoodi_tt2_transfer($to_address TEXT, $amount TEXT) PUBL } -- Fee - $fee := 1000000000000000000::NUMERIC(78, 0); -- 1 TT2 with 18 decimals + -- One cent, not one token. TT2 is the testnet stand-in for USDC, which people + -- send in small amounts, and a whole-token fee priced most of those out. The + -- fee still has to bite enough to make spamming transfers expensive. + -- + -- TT2 carries 18 decimals while mainnet USDC carries 6, so the same one cent + -- is 10^16 base units here and 10^4 in eth_usdc_transfer. + $fee := 10000000000000000::NUMERIC(78, 0); -- 0.01 TT2 with 18 decimals $caller_balance := COALESCE(hoodi_tt2.balance(@caller), 0::NUMERIC(78, 0)); IF @leader_sender IS NULL { @@ -148,7 +154,7 @@ CREATE OR REPLACE ACTION hoodi_tt2_transfer($to_address TEXT, $amount TEXT) PUBL $leader_hex TEXT := encode(@leader_sender, 'hex')::TEXT; IF ($caller_balance < ($amount::NUMERIC(78, 0) + $fee)) { - ERROR('Insufficient balance for transfer. Requires an extra 1 TT2 fee on top of the transfer amount'); + ERROR('Insufficient balance for transfer. Requires an extra 0.01 TT2 fee on top of the transfer amount'); } hoodi_tt2.transfer($leader_hex, $fee); diff --git a/tests/extensions/erc20/erc20_bridge_transfer_actions_test.go b/tests/extensions/erc20/erc20_bridge_transfer_actions_test.go index 3e5751c9..45935f14 100644 --- a/tests/extensions/erc20/erc20_bridge_transfer_actions_test.go +++ b/tests/extensions/erc20/erc20_bridge_transfer_actions_test.go @@ -470,3 +470,96 @@ func callHoodiWalletBalance(ctx context.Context, platform *kwilTesting.Platform, } return balance, nil } + +// hoodi_tt2 bridge constants, matching erc20-bridge/000-extension.sql. The casing is copied verbatim +// from the migration for the same reason as hoodi_tt above: the instance id is derived from the +// escrow string, so a differently-cased one addresses a bridge nothing ever registered. +const ( + hoodiTT2Chain = "hoodi" + hoodiTT2Escrow = "0x80D9B3b6941367917816d36748C88B303f7F1415" + hoodiTT2ERC20 = "0x1591DeAa21710E0BA6CC1b15F49620C9F65B2dEd" +) + +// TestHoodiTT2TransferFee pins hoodi_tt2_transfer's fee at one cent. +// +// TT2 is the testnet stand-in for USDC, which people send in amounts where a whole-token fee is the +// larger half of the transfer. The balance assertions below are exact, so moving the fee in either +// direction fails here rather than on somebody's testnet wallet. TT2 carries 18 decimals while +// mainnet USDC carries 6, so the cent is 10^16 here and 10^4 in the eth_usdc_transfer override. +func TestHoodiTT2TransferFee(t *testing.T) { + seedAndRun(t, "hoodi_tt2_transfer_fee", func(ctx context.Context, platform *kwilTesting.Platform) error { + // Activate the bridge singleton (loads + syncs the migration-registered instances, incl. hoodi_tt2). + require.NoError(t, erc20shim.ForTestingInitializeExtension(ctx, platform)) + + // Credit UserA with 2.0 TT2. Single deposit -> nil prev. + initialAmount := "2000000000000000000" + require.NoError(t, testerc20.InjectERC20Transfer(ctx, platform, + hoodiTT2Chain, hoodiTT2Escrow, hoodiTT2ERC20, TestUserA, TestUserA, initialAmount, 20, nil)) + + // Transfer 1.0 TT2 from A to B. + require.NoError(t, callHoodiTT2Transfer(ctx, platform, TestUserA, TestUserB, TestAmount1)) + + // UserA: 2.0 - 1.0 transfer - 0.01 fee = 0.99. + balanceA, err := callHoodiTT2WalletBalance(ctx, platform, TestUserA) + require.NoError(t, err) + require.Equal(t, "990000000000000000", balanceA, "UserA should be charged exactly one cent on top of the transfer") + + // UserB: received the transfer amount, never the fee. + balanceB, err := callHoodiTT2WalletBalance(ctx, platform, TestUserB) + require.NoError(t, err) + require.Equal(t, TestAmount1, balanceB, "UserB should have received the transferred amount") + + // UserB holds exactly 1.0, so 0.99 plus the cent spends it to the last base unit. The same + // transfer was rejected outright while the fee was a whole token. + require.NoError(t, callHoodiTT2Transfer(ctx, platform, TestUserB, TestUserC, "990000000000000000")) + + balanceB, err = callHoodiTT2WalletBalance(ctx, platform, TestUserB) + require.NoError(t, err) + require.Equal(t, "0", balanceB, "0.99 plus the cent fee should spend UserB's balance exactly") + + // UserD holds nothing, so the shortfall message has to quote the fee a caller now needs. + err = callHoodiTT2Transfer(ctx, platform, TestUserD, TestUserA, TestAmount1) + require.Error(t, err) + require.Contains(t, err.Error(), "Insufficient balance for transfer") + require.Contains(t, err.Error(), "Requires an extra 0.01 TT2 fee") + + return nil + }) +} + +// Helper function to call hoodi_tt2_transfer action +func callHoodiTT2Transfer(ctx context.Context, platform *kwilTesting.Platform, from, to, amount string) error { + engineCtx := engCtx(ctx, platform, from, 1, false) + + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "hoodi_tt2_transfer", []any{to, amount}, func(row *common.Row) error { + return nil + }) + if err != nil { + return err + } + if res != nil && res.Error != nil { + return res.Error + } + return nil +} + +// Helper function to call hoodi_tt2_wallet_balance action +func callHoodiTT2WalletBalance(ctx context.Context, platform *kwilTesting.Platform, userAddr string) (string, error) { + engineCtx := engCtx(ctx, platform, "0x0000000000000000000000000000000000000000", 1, false) + + var balance string + res, err := platform.Engine.Call(engineCtx, platform.DB, "", "hoodi_tt2_wallet_balance", []any{userAddr}, func(row *common.Row) error { + if len(row.Values) != 1 { + return fmt.Errorf("expected 1 column, got %d", len(row.Values)) + } + balance = row.Values[0].(*types.Decimal).String() + return nil + }) + if err != nil { + return "", err + } + if res != nil && res.Error != nil { + return "", res.Error + } + return balance, nil +} diff --git a/tests/streams/transaction_events_ledger_test.go b/tests/streams/transaction_events_ledger_test.go index 97d956df..82cafdb8 100644 --- a/tests/streams/transaction_events_ledger_test.go +++ b/tests/streams/transaction_events_ledger_test.go @@ -258,7 +258,8 @@ func runTransactionEventsLedgerScenario(t *testing.T) func(ctx context.Context, // A transfer is the one method that does not always charge its fee in // $TRUF: it charges in the bridge it moves. The ledger has no token // column, so unless each action names its own bridge a reader cannot - // tell a 1 TRUF fee from a 1 USDC one, and the two differ by 1e12. + // tell a $TRUF fee from a USDC one. On mainnet the two are 1 TRUF and a + // cent of USDC, 1e18 against 1e4. assertBridge := func(want string) func(meta metadataMap) { return func(meta metadataMap) { require.Equal(t, want, meta.String("bridge"),