From aa673fb26d4a9af6dd90bfd0ee73752b0dc928f9 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 25 Dec 2025 12:52:34 +0100 Subject: [PATCH] EVM, ARB, ETH gas top up policies + tron config change --- api/gateway/chain/config.yml | 6 +- .../gateway/commands/transfer/gas_topup.go | 9 ++ .../service/gateway/driver/evm/gas_topup.go | 108 +++++++++++++ .../gateway/driver/evm/gas_topup_test.go | 146 ++++++++++++++++++ 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/evm/gas_topup_test.go diff --git a/api/gateway/chain/config.yml b/api/gateway/chain/config.yml index ca102cb..0426314 100644 --- a/api/gateway/chain/config.yml +++ b/api/gateway/chain/config.yml @@ -39,10 +39,10 @@ chains: native_token: TRX rpc_url_env: CHAIN_GATEWAY_RPC_URL gas_topup_policy: - buffer_percent: 0.15 - min_native_balance_trx: 20 + buffer_percent: 0.10 + min_native_balance_trx: 10 rounding_unit_trx: 1 - max_topup_trx: 500 + max_topup_trx: 100 tokens: - symbol: USDT contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c" diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go b/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go index 28d11ba..2b5f4a5 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go @@ -6,6 +6,7 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage/model" @@ -171,6 +172,14 @@ func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimated return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil } + if networkCfg.GasTopUpPolicy != nil { + topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, capHit, nil, nativeBalance, walletModel, nil + } + topUp, err := defaultGasTopUp(estimatedFee, nativeBalance) if err != nil { return nil, false, nil, nil, nil, err diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go new file mode 100644 index 0000000..75fdbe2 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go @@ -0,0 +1,108 @@ +package evm + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000) + +// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount. +func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) { + if wallet == nil { + return nil, false, merrors.InvalidArgument("wallet is required") + } + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, false, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, false, merrors.InvalidArgument("current native balance is required") + } + if network.GasTopUpPolicy == nil { + return nil, false, merrors.InvalidArgument("gas top-up policy is not configured") + } + + nativeCurrency := strings.TrimSpace(network.NativeToken) + if nativeCurrency == "" { + nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name)) + } + if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) { + return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency)) + } + if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) { + return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency)) + } + + estimatedNative, err := evmToNative(estimatedFee) + if err != nil { + return nil, false, err + } + currentNative, err := evmToNative(currentBalance) + if err != nil { + return nil, false, err + } + + isContract := strings.TrimSpace(wallet.ContractAddress) != "" + rule, ok := network.GasTopUpPolicy.Rule(isContract) + if !ok { + return nil, false, merrors.InvalidArgument("gas top-up policy is not configured") + } + if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) { + return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0") + } + if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) { + return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0") + } + + required := estimatedNative.Sub(currentNative) + if required.IsNegative() { + required = decimal.Zero + } + bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent)) + + minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative) + if minBalanceTopUp.IsNegative() { + minBalanceTopUp = decimal.Zero + } + + rawTopUp := bufferedRequired + if minBalanceTopUp.GreaterThan(rawTopUp) { + rawTopUp = minBalanceTopUp + } + + roundedTopUp := decimal.Zero + if rawTopUp.IsPositive() { + roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit) + } + + topUp := roundedTopUp + capHit := false + if topUp.GreaterThan(rule.MaxTopUp) { + topUp = rule.MaxTopUp + capHit = true + } + + if !topUp.IsPositive() { + return nil, capHit, nil + } + + baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0) + return &moneyv1.Money{ + Currency: strings.ToUpper(nativeCurrency), + Amount: baseUnits.StringFixed(0), + }, capHit, nil +} + +func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount())) + if err != nil { + return decimal.Zero, err + } + return value.Div(evmBaseUnitFactor), nil +} diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup_test.go b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup_test.go new file mode 100644 index 0000000..6a78b71 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup_test.go @@ -0,0 +1,146 @@ +package evm + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestComputeGasTopUp_BalanceSufficient(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30")) + require.NoError(t, err) + require.Nil(t, topUp) + require.False(t, capHit) +} + +func TestComputeGasTopUp_BufferedRequired(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "46000000000000000000", topUp.GetAmount()) + require.Equal(t, "ETH", topUp.GetCurrency()) +} + +func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "19000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_RoundsUp(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "2000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_CapHit(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(10), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.True(t, capHit) + require.Equal(t, "10000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "15000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.1), + MinNativeBalance: decimal.NewFromFloat(10), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + Contract: &shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.5), + MinNativeBalance: decimal.NewFromFloat(5), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{ContractAddress: "0xcontract"} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "15000000000000000000", topUp.GetAmount()) +} + +func defaultPolicy() *shared.GasTopUpPolicy { + return &shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.15), + MinNativeBalance: decimal.NewFromFloat(20), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(500), + }, + } +} + +func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network { + return shared.Network{ + Name: "ethereum_mainnet", + NativeToken: "ETH", + GasTopUpPolicy: policy, + } +} + +func ethMoney(eth string) *moneyv1.Money { + value, _ := decimal.NewFromString(eth) + baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0) + return &moneyv1.Money{ + Currency: "ETH", + Amount: baseUnits.StringFixed(0), + } +}