Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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 {
Expand All @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions internal/migrations/erc20-bridge/002-public-transfer-actions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
93 changes: 93 additions & 0 deletions tests/extensions/erc20/erc20_bridge_transfer_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion tests/streams/transaction_events_ledger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading