EVM, ARB, ETH gas top up policies + tron config change #162

Merged
tech merged 1 commits from tron-161 into main 2025-12-25 11:54:03 +00:00
4 changed files with 266 additions and 3 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}

View File

@@ -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),
}
}