From 31d93e51134dffa4e0a3745e736973ac15a2431b Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 25 Dec 2025 12:26:24 +0100 Subject: [PATCH] Gas topup limits --- api/gateway/chain/client/client.go | 16 + api/gateway/chain/client/fake.go | 16 + api/gateway/chain/config.yml | 5 + .../internal/server/internal/serverimp.go | 104 ++++++- .../service/gateway/commands/registry.go | 4 + .../gateway/commands/transfer/gas_topup.go | 281 ++++++++++++++++++ .../gateway/commands/wallet/balance.go | 2 +- .../commands/wallet/onchain_balance.go | 2 +- .../service/gateway/driver/tron/gas_topup.go | 143 +++++++++ .../gateway/driver/tron/gas_topup_test.go | 147 +++++++++ .../chain/internal/service/gateway/service.go | 8 + .../service/gateway/shared/gas_topup.go | 32 ++ .../service/gateway/shared/helpers.go | 11 +- .../service/orchestrator/card_payout.go | 158 +++++----- .../service/orchestrator/card_payout_test.go | 58 ++-- api/proto/gateway/chain/v1/chain.proto | 28 ++ 16 files changed, 906 insertions(+), 109 deletions(-) create mode 100644 api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/tron/gas_topup.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/tron/gas_topup_test.go create mode 100644 api/gateway/chain/internal/service/gateway/shared/gas_topup.go diff --git a/api/gateway/chain/client/client.go b/api/gateway/chain/client/client.go index 49149da..71c3a22 100644 --- a/api/gateway/chain/client/client.go +++ b/api/gateway/chain/client/client.go @@ -24,6 +24,8 @@ type Client interface { GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) + ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) + EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) Close() error } @@ -36,6 +38,8 @@ type grpcGatewayClient interface { GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error) ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error) EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error) + ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error) + EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error) } type chainGatewayClient struct { @@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain return c.client.EstimateTransferFee(ctx, req) } +func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.ComputeGasTopUp(ctx, req) +} + +func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.EnsureGasTopUp(ctx, req) +} + func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { timeout := c.cfg.CallTimeout if timeout <= 0 { diff --git a/api/gateway/chain/client/fake.go b/api/gateway/chain/client/fake.go index 59e57d0..a95da43 100644 --- a/api/gateway/chain/client/fake.go +++ b/api/gateway/chain/client/fake.go @@ -16,6 +16,8 @@ type Fake struct { GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) + ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) + EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) CloseFn func() error } @@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra return &chainv1.EstimateTransferFeeResponse{}, nil } +func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + if f.ComputeGasTopUpFn != nil { + return f.ComputeGasTopUpFn(ctx, req) + } + return &chainv1.ComputeGasTopUpResponse{}, nil +} + +func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + if f.EnsureGasTopUpFn != nil { + return f.EnsureGasTopUpFn(ctx, req) + } + return &chainv1.EnsureGasTopUpResponse{}, nil +} + func (f *Fake) Close() error { if f.CloseFn != nil { return f.CloseFn() diff --git a/api/gateway/chain/config.yml b/api/gateway/chain/config.yml index a8b23c5..ca102cb 100644 --- a/api/gateway/chain/config.yml +++ b/api/gateway/chain/config.yml @@ -38,6 +38,11 @@ chains: chain_id: 728126428 # 0x2b6653dc native_token: TRX rpc_url_env: CHAIN_GATEWAY_RPC_URL + gas_topup_policy: + buffer_percent: 0.15 + min_native_balance_trx: 20 + rounding_unit_trx: 1 + max_topup_trx: 500 tokens: - symbol: USDT contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c" diff --git a/api/gateway/chain/internal/server/internal/serverimp.go b/api/gateway/chain/internal/server/internal/serverimp.go index d1229bd..5fc02a3 100644 --- a/api/gateway/chain/internal/server/internal/serverimp.go +++ b/api/gateway/chain/internal/server/internal/serverimp.go @@ -8,6 +8,7 @@ import ( "time" "github.com/mitchellh/mapstructure" + "github.com/shopspring/decimal" "github.com/tech/sendico/gateway/chain/internal/keymanager" vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault" gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway" @@ -46,11 +47,12 @@ type config struct { } type chainConfig struct { - Name string `yaml:"name"` - RPCURLEnv string `yaml:"rpc_url_env"` - ChainID uint64 `yaml:"chain_id"` - NativeToken string `yaml:"native_token"` - Tokens []tokenConfig `yaml:"tokens"` + Name string `yaml:"name"` + RPCURLEnv string `yaml:"rpc_url_env"` + ChainID uint64 `yaml:"chain_id"` + NativeToken string `yaml:"native_token"` + Tokens []tokenConfig `yaml:"tokens"` + GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"` } type serviceWalletConfig struct { @@ -66,6 +68,19 @@ type tokenConfig struct { ContractEnv string `yaml:"contract_env"` } +type gasTopUpPolicyConfig struct { + gasTopUpRuleConfig `yaml:",inline"` + Native *gasTopUpRuleConfig `yaml:"native"` + Contract *gasTopUpRuleConfig `yaml:"contract"` +} + +type gasTopUpRuleConfig struct { + BufferPercent float64 `yaml:"buffer_percent"` + MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"` + RoundingUnitTRX float64 `yaml:"rounding_unit_trx"` + MaxTopUpTRX float64 `yaml:"max_topup_trx"` +} + // Create initialises the chain gateway server implementation. func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { return &Imp{ @@ -217,17 +232,86 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew }) } + gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy) + if err != nil { + logger.Error("invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err)) + return nil, err + } + result = append(result, gatewayshared.Network{ - Name: chain.Name, - RPCURL: rpcURL, - ChainID: chain.ChainID, - NativeToken: chain.NativeToken, - TokenConfigs: contracts, + Name: chain.Name, + RPCURL: rpcURL, + ChainID: chain.ChainID, + NativeToken: chain.NativeToken, + TokenConfigs: contracts, + GasTopUpPolicy: gasPolicy, }) } return result, nil } +func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) { + if cfg == nil { + return nil, nil + } + defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig) + if err != nil { + return nil, err + } + if !defaultSet { + return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName)) + } + + policy := &gatewayshared.GasTopUpPolicy{ + Default: defaultRule, + } + + if cfg.Native != nil { + rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native) + if err != nil { + return nil, err + } + if set { + policy.Native = &rule + } + } + if cfg.Contract != nil { + rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract) + if err != nil { + return nil, err + } + if set { + policy.Contract = &rule + } + } + + return policy, nil +} + +func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) { + if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 { + return gatewayshared.GasTopUpRule{}, false, nil + } + if cfg.BufferPercent < 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label)) + } + if cfg.MinNativeBalanceTRX < 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label)) + } + if cfg.RoundingUnitTRX <= 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label)) + } + if cfg.MaxTopUpTRX <= 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label)) + } + return gatewayshared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(cfg.BufferPercent), + MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX), + RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX), + MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX), + }, true, nil +} + func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet { address := strings.TrimSpace(cfg.Address) if address == "" && cfg.AddressEnv != "" { diff --git a/api/gateway/chain/internal/service/gateway/commands/registry.go b/api/gateway/chain/internal/service/gateway/commands/registry.go index dec967c..b3861f8 100644 --- a/api/gateway/chain/internal/service/gateway/commands/registry.go +++ b/api/gateway/chain/internal/service/gateway/commands/registry.go @@ -23,6 +23,8 @@ type Registry struct { GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse] ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse] EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse] + ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse] + EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse] } type RegistryDeps struct { @@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry { GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")), ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")), EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")), + ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")), + EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")), } } 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 new file mode 100644 index 0000000..28d11ba --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go @@ -0,0 +1,281 @@ +package transfer + +import ( + "context" + "strings" + + "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/tron" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type computeGasTopUpCommand struct { + deps Deps +} + +func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand { + return &computeGasTopUpCommand{deps: deps} +} + +func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("nil request") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) + } + + walletRef := strings.TrimSpace(req.GetWalletRef()) + if walletRef == "" { + c.deps.Logger.Warn("wallet ref missing") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) + } + estimatedFee := req.GetEstimatedTotalFee() + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + c.deps.Logger.Warn("estimated fee missing") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) + } + + topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee) + if err != nil { + return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel) + + return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{ + TopupAmount: topUp, + CapHit: capHit, + }) +} + +type ensureGasTopUpCommand struct { + deps Deps +} + +func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand { + return &ensureGasTopUpCommand{deps: deps} +} + +func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("nil request") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + c.deps.Logger.Warn("idempotency key missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) + } + organizationRef := strings.TrimSpace(req.GetOrganizationRef()) + if organizationRef == "" { + c.deps.Logger.Warn("organization ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) + } + sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) + if sourceWalletRef == "" { + c.deps.Logger.Warn("source wallet ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) + } + targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef()) + if targetWalletRef == "" { + c.deps.Logger.Warn("target wallet ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required")) + } + estimatedFee := req.GetEstimatedTotalFee() + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + c.deps.Logger.Warn("estimated fee missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) + } + + topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee) + if err != nil { + return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel) + + if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" { + return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{ + TopupAmount: nil, + CapHit: capHit, + }) + } + + submitReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + SourceWalletRef: sourceWalletRef, + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef}, + }, + Amount: topUp, + Metadata: shared.CloneMetadata(req.GetMetadata()), + ClientReference: strings.TrimSpace(req.GetClientReference()), + } + + submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq) + return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) { + submitResp, err := submitResponder(ctx) + if err != nil { + return nil, err + } + return &chainv1.EnsureGasTopUpResponse{ + TopupAmount: topUp, + CapHit: capHit, + Transfer: submitResp.GetTransfer(), + }, nil + } +} + +func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) { + walletRef = strings.TrimSpace(walletRef) + estimatedFee = shared.CloneMoney(estimatedFee) + walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef) + if err != nil { + return nil, false, nil, nil, nil, err + } + + networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network)) + networkCfg, ok := deps.Networks.Network(networkKey) + if !ok { + return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet") + } + + nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel) + if err != nil { + return nil, false, nil, nil, nil, err + } + + if strings.HasPrefix(networkKey, "tron") { + topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil + } + + topUp, err := defaultGasTopUp(estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, false, nil, nativeBalance, walletModel, nil +} + +func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) { + if walletModel == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + walletDeps := wallet.Deps{ + Logger: deps.Logger.Named("wallet"), + Drivers: deps.Drivers, + Networks: deps.Networks, + KeyManager: nil, + Storage: deps.Storage, + Clock: deps.Clock, + BalanceCacheTTL: 0, + RPCTimeout: deps.RPCTimeout, + EnsureRepository: deps.EnsureRepository, + } + _, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel) + if err != nil { + return nil, err + } + if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("native balance is unavailable") + } + return nativeBalance, nil +} + +func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) { + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("native balance is required") + } + if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) { + return nil, merrors.InvalidArgument("native balance currency mismatch") + } + + estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount())) + if err != nil { + return nil, err + } + current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount())) + if err != nil { + return nil, err + } + required := estimated.Sub(current) + if !required.IsPositive() { + return nil, nil + } + return &moneyv1.Money{ + Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())), + Amount: required.String(), + }, nil +} + +func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) { + if logger == nil { + return + } + fields := []zap.Field{ + zap.String("wallet_ref", walletRef), + zap.String("estimated_total_fee", amountString(estimatedFee)), + zap.String("current_native_balance", amountString(nativeBalance)), + zap.String("topup_amount", amountString(topUp)), + zap.Bool("cap_hit", capHit), + } + if walletModel != nil { + fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network))) + } + if decision != nil { + fields = append(fields, + zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()), + zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()), + zap.String("required_trx", decision.RequiredTRX.String()), + zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()), + zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()), + zap.String("raw_topup_trx", decision.RawTopUpTRX.String()), + zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()), + zap.String("topup_trx", decision.TopUpTRX.String()), + zap.String("operation_type", decision.OperationType), + ) + } + logger.Info("gas top-up decision", fields...) +} + +func amountString(m *moneyv1.Money) string { + if m == nil { + return "" + } + amount := strings.TrimSpace(m.GetAmount()) + currency := strings.TrimSpace(m.GetCurrency()) + if amount == "" && currency == "" { + return "" + } + if currency == "" { + return amount + } + if amount == "" { + return currency + } + return amount + " " + currency +} diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/balance.go b/api/gateway/chain/internal/service/gateway/commands/wallet/balance.go index bd2d1bd..6305e21 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/balance.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/balance.go @@ -51,7 +51,7 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) } - tokenBalance, nativeBalance, chainErr := onChainWalletBalances(ctx, c.deps, wallet) + tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet) if chainErr != nil { c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef)) stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef) diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go index a938626..aa59404 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go @@ -12,7 +12,7 @@ import ( "go.uber.org/zap" ) -func onChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) { +func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) { logger := deps.Logger if wallet == nil { return nil, nil, merrors.InvalidArgument("wallet is required") diff --git a/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup.go b/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup.go new file mode 100644 index 0000000..28159ae --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup.go @@ -0,0 +1,143 @@ +package tron + +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 tronBaseUnitFactor = decimal.NewFromInt(1_000_000) + +// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units). +type GasTopUpDecision struct { + CurrentBalanceTRX decimal.Decimal + EstimatedFeeTRX decimal.Decimal + RequiredTRX decimal.Decimal + BufferedRequiredTRX decimal.Decimal + MinBalanceTopUpTRX decimal.Decimal + RawTopUpTRX decimal.Decimal + RoundedTopUpTRX decimal.Decimal + TopUpTRX decimal.Decimal + CapHit bool + OperationType string +} + +// ComputeGasTopUp applies the network policy to decide a TRX top-up amount. +func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) { + decision := GasTopUpDecision{} + if wallet == nil { + return nil, decision, merrors.InvalidArgument("wallet is required") + } + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, decision, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, decision, merrors.InvalidArgument("current native balance is required") + } + if network.GasTopUpPolicy == nil { + return nil, decision, 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, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency)) + } + if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) { + return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency)) + } + + estimatedTRX, err := tronToTRX(estimatedFee) + if err != nil { + return nil, decision, err + } + currentTRX, err := tronToTRX(currentBalance) + if err != nil { + return nil, decision, err + } + + isContract := strings.TrimSpace(wallet.ContractAddress) != "" + rule, ok := network.GasTopUpPolicy.Rule(isContract) + if !ok { + return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured") + } + if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) { + return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0") + } + if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) { + return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0") + } + + required := estimatedTRX.Sub(currentTRX) + if required.IsNegative() { + required = decimal.Zero + } + bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent)) + + minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX) + 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 + } + + decision = GasTopUpDecision{ + CurrentBalanceTRX: currentTRX, + EstimatedFeeTRX: estimatedTRX, + RequiredTRX: required, + BufferedRequiredTRX: bufferedRequired, + MinBalanceTopUpTRX: minBalanceTopUp, + RawTopUpTRX: rawTopUp, + RoundedTopUpTRX: roundedTopUp, + TopUpTRX: topUp, + CapHit: capHit, + OperationType: operationType(isContract), + } + + if !topUp.IsPositive() { + return nil, decision, nil + } + + baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0) + return &moneyv1.Money{ + Currency: strings.ToUpper(nativeCurrency), + Amount: baseUnits.StringFixed(0), + }, decision, nil +} + +func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount())) + if err != nil { + return decimal.Zero, err + } + return value.Div(tronBaseUnitFactor), nil +} + +func operationType(contract bool) string { + if contract { + return "trc20" + } + return "native" +} diff --git a/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup_test.go b/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup_test.go new file mode 100644 index 0000000..0cc7b19 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/tron/gas_topup_test.go @@ -0,0 +1,147 @@ +package tron + +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 := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30")) + require.NoError(t, err) + require.Nil(t, topUp) + require.True(t, decision.TopUpTRX.IsZero()) +} + +func TestComputeGasTopUp_BufferedRequired(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "46000000", topUp.GetAmount()) + require.Equal(t, "TRX", topUp.GetCurrency()) + require.Equal(t, "46", decision.TopUpTRX.String()) +} + +func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "19000000", topUp.GetAmount()) + require.Equal(t, "19", decision.TopUpTRX.String()) +} + +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 := tronNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "2000000", topUp.GetAmount()) + require.Equal(t, "2", decision.TopUpTRX.String()) +} + +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 := tronNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "10000000", topUp.GetAmount()) + require.True(t, decision.CapHit) +} + +func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "15000000", topUp.GetAmount()) + require.Equal(t, "15", decision.TopUpTRX.String()) +} + +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 := tronNetwork(&policy) + wallet := &model.ManagedWallet{ContractAddress: "0xcontract"} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "15000000", topUp.GetAmount()) + require.Equal(t, "15", decision.TopUpTRX.String()) + require.Equal(t, "trc20", decision.OperationType) +} + +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 tronNetwork(policy *shared.GasTopUpPolicy) shared.Network { + return shared.Network{ + Name: "tron_mainnet", + NativeToken: "TRX", + GasTopUpPolicy: policy, + } +} + +func tronMoney(trx string) *moneyv1.Money { + value, _ := decimal.NewFromString(trx) + baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0) + return &moneyv1.Money{ + Currency: "TRX", + Amount: baseUnits.StringFixed(0), + } +} diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index c00f117..6d50a6f 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -126,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req) } +func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req) +} + +func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req) +} + func (s *Service) ensureRepository(ctx context.Context) error { if s.storage == nil { return errStorageUnavailable diff --git a/api/gateway/chain/internal/service/gateway/shared/gas_topup.go b/api/gateway/chain/internal/service/gateway/shared/gas_topup.go new file mode 100644 index 0000000..cfb2b51 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/shared/gas_topup.go @@ -0,0 +1,32 @@ +package shared + +import "github.com/shopspring/decimal" + +// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups. +type GasTopUpRule struct { + BufferPercent decimal.Decimal + MinNativeBalance decimal.Decimal + RoundingUnit decimal.Decimal + MaxTopUp decimal.Decimal +} + +// GasTopUpPolicy captures default and optional overrides for native vs contract transfers. +type GasTopUpPolicy struct { + Default GasTopUpRule + Native *GasTopUpRule + Contract *GasTopUpRule +} + +// Rule selects the policy rule for the transfer type. +func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) { + if p == nil { + return GasTopUpRule{}, false + } + if contractTransfer && p.Contract != nil { + return *p.Contract, true + } + if !contractTransfer && p.Native != nil { + return *p.Native, true + } + return p.Default, true +} diff --git a/api/gateway/chain/internal/service/gateway/shared/helpers.go b/api/gateway/chain/internal/service/gateway/shared/helpers.go index 38f7b04..c40c5b7 100644 --- a/api/gateway/chain/internal/service/gateway/shared/helpers.go +++ b/api/gateway/chain/internal/service/gateway/shared/helpers.go @@ -121,11 +121,12 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus { // Network describes a supported blockchain network and known token contracts. type Network struct { - Name string - RPCURL string - ChainID uint64 - NativeToken string - TokenConfigs []TokenContract + Name string + RPCURL string + ChainID uint64 + NativeToken string + TokenConfigs []TokenContract + GasTopUpPolicy *GasTopUpPolicy } // TokenContract captures the metadata needed to work with a specific on-chain token. diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go index ffe9b2d..29bc8fc 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go @@ -117,79 +117,58 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model } } - requiredGas, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) + totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) if err != nil { return err } - balanceResp, err := s.deps.gateway.client.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{ - WalletRef: sourceWalletRef, - }) - if err != nil { - s.logger.Warn("card funding balance check failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - if balanceResp == nil { - return merrors.Internal("card funding: balance unavailable") - } - var nativeAvailable *moneyv1.Money - if balance := balanceResp.GetBalance(); balance != nil { - nativeAvailable = balance.GetNativeAvailable() - } - available := decimal.Zero - availableCurrency := "" - if nativeAvailable != nil && strings.TrimSpace(nativeAvailable.GetAmount()) != "" { - if strings.TrimSpace(nativeAvailable.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: native balance currency is required") - } - available, err = decimalFromMoney(nativeAvailable) - if err != nil { - return err - } - availableCurrency = strings.TrimSpace(nativeAvailable.GetCurrency()) - } - if requiredGas.IsPositive() { - if availableCurrency == "" { - availableCurrency = gasCurrency - } - if gasCurrency != "" && availableCurrency != "" && !strings.EqualFold(gasCurrency, availableCurrency) { - return merrors.InvalidArgument("card funding: native balance currency mismatch") - } - } - - topUpAmount := decimal.Zero - if requiredGas.IsPositive() { - topUpAmount = requiredGas.Sub(available) - if topUpAmount.IsNegative() { - topUpAmount = decimal.Zero - } + var estimatedTotalFee *moneyv1.Money + if gasCurrency != "" && !totalFee.IsNegative() { + estimatedTotalFee = makeMoney(gasCurrency, totalFee) } var topUpMoney *moneyv1.Money var topUpFee *moneyv1.Money - if topUpAmount.IsPositive() { - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") - } - if gasCurrency == "" { - gasCurrency = availableCurrency - } - if gasCurrency == "" { - return merrors.InvalidArgument("card funding: native currency is required for gas top-up") - } - topUpMoney = makeMoney(gasCurrency, topUpAmount) - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney) + topUpPositive := false + if estimatedTotalFee != nil { + computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ + WalletRef: sourceWalletRef, + EstimatedTotalFee: estimatedTotalFee, + }) if err != nil { + s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) return err } + if computeResp != nil { + topUpMoney = computeResp.GetTopupAmount() + } + if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" { + amountDec, err := decimalFromMoney(topUpMoney) + if err != nil { + return err + } + topUpPositive = amountDec.IsPositive() + } + if topUpMoney != nil && topUpPositive { + if strings.TrimSpace(topUpMoney.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: gas top-up currency is required") + } + if feeWalletRef == "" { + return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") + } + topUpDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, + } + topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney) + if err != nil { + return err + } + } } plan := ensureExecutionPlan(payment) var gasStep *model.ExecutionStep - if topUpMoney != nil && topUpAmount.IsPositive() { + if topUpMoney != nil && topUpPositive { gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) gasStep.Description = "Top up native gas from fee wallet" gasStep.Amount = cloneMoney(topUpMoney) @@ -230,27 +209,58 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model exec = &model.ExecutionRefs{} } - if topUpMoney != nil && topUpAmount.IsPositive() { - gasReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:gas", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: feeWalletRef, - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - }, - Amount: topUpMoney, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - } - gasResp, gasErr := s.deps.gateway.client.SubmitTransfer(ctx, gasReq) + if topUpMoney != nil && topUpPositive { + ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:gas", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: feeWalletRef, + TargetWalletRef: sourceWalletRef, + EstimatedTotalFee: estimatedTotalFee, + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + }) if gasErr != nil { s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) return gasErr } - if gasResp != nil && gasResp.GetTransfer() != nil { - gasStep.TransferRef = strings.TrimSpace(gasResp.GetTransfer().GetTransferRef()) + if gasStep != nil { + actual := (*moneyv1.Money)(nil) + if ensureResp != nil { + actual = ensureResp.GetTopupAmount() + if transfer := ensureResp.GetTransfer(); transfer != nil { + gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef()) + } + } + actualPositive := false + if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" { + actualDec, err := decimalFromMoney(actual) + if err != nil { + return err + } + actualPositive = actualDec.IsPositive() + } + if actual != nil && actualPositive { + gasStep.Amount = cloneMoney(actual) + if strings.TrimSpace(actual.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: gas top-up currency is required") + } + topUpDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, + } + topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual) + if err != nil { + return err + } + gasStep.NetworkFee = cloneMoney(topUpFee) + } else { + gasStep.Amount = nil + gasStep.NetworkFee = nil + } } - s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) + if gasStep != nil { + s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) + } + updateExecutionPlanTotalNetworkFee(plan) } // Transfer payout amount to funding wallet. diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go index 692084f..72bea5b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -27,6 +27,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { ) var estimateCalls []*chainv1.EstimateTransferFeeRequest + var computeCalls []*chainv1.ComputeGasTopUpRequest + var ensureCalls []*chainv1.EnsureGasTopUpRequest var submitCalls []*chainv1.SubmitTransferRequest gateway := &chainclient.Fake{ @@ -47,11 +49,17 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"}, }, nil }, - GetWalletBalanceFn: func(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { - return &chainv1.GetWalletBalanceResponse{ - Balance: &chainv1.WalletBalance{ - NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.005"}, - }, + ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + computeCalls = append(computeCalls, req) + return &chainv1.ComputeGasTopUpResponse{ + TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, + }, nil + }, + EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + ensureCalls = append(ensureCalls, req) + return &chainv1.EnsureGasTopUpResponse{ + TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, + Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, }, nil }, SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { @@ -105,22 +113,36 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { t.Fatalf("submitCardFundingTransfers error: %v", err) } - if len(estimateCalls) != 3 { - t.Fatalf("expected 3 fee estimates, got %d", len(estimateCalls)) + if len(estimateCalls) != 4 { + t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls)) } - if len(submitCalls) != 2 { - t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls)) + if len(computeCalls) != 1 { + t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls)) + } + if len(ensureCalls) != 1 { + t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls)) + } + if len(submitCalls) != 1 { + t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls)) } - gasCall := findSubmitCall(t, submitCalls, "pay-1:card:gas") - if gasCall.GetSourceWalletRef() != feeWalletRef { - t.Fatalf("gas top-up source wallet mismatch: %s", gasCall.GetSourceWalletRef()) + computeCall := computeCalls[0] + if computeCall.GetWalletRef() != sourceWalletRef { + t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef()) } - if gasCall.GetDestination().GetManagedWalletRef() != sourceWalletRef { - t.Fatalf("gas top-up destination mismatch: %s", gasCall.GetDestination().GetManagedWalletRef()) + if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" { + t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount()) } - if gasCall.GetAmount().GetCurrency() != "ETH" || gasCall.GetAmount().GetAmount() != "0.025" { - t.Fatalf("gas top-up amount mismatch: %s %s", gasCall.GetAmount().GetCurrency(), gasCall.GetAmount().GetAmount()) + + ensureCall := ensureCalls[0] + if ensureCall.GetSourceWalletRef() != feeWalletRef { + t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef()) + } + if ensureCall.GetTargetWalletRef() != sourceWalletRef { + t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef()) + } + if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" { + t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount()) } fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund") @@ -146,8 +168,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" { t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount()) } - if gasStep.TransferRef == "" { - t.Fatalf("expected gas step transfer ref to be set") + if gasStep.TransferRef != "pay-1:card:gas" { + t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef) } fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer) diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index 44d24bb..932ee1d 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -189,6 +189,32 @@ message EstimateTransferFeeResponse { string estimation_context = 2; } +message ComputeGasTopUpRequest { + string wallet_ref = 1; + common.money.v1.Money estimated_total_fee = 2; +} + +message ComputeGasTopUpResponse { + common.money.v1.Money topup_amount = 1; + bool cap_hit = 2; +} + +message EnsureGasTopUpRequest { + string idempotency_key = 1; + string organization_ref = 2; + string source_wallet_ref = 3; + string target_wallet_ref = 4; + common.money.v1.Money estimated_total_fee = 5; + map metadata = 6; + string client_reference = 7; +} + +message EnsureGasTopUpResponse { + common.money.v1.Money topup_amount = 1; + bool cap_hit = 2; + Transfer transfer = 3; +} + message WalletDepositObservedEvent { string deposit_ref = 1; string wallet_ref = 2; @@ -218,4 +244,6 @@ service ChainGatewayService { rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse); rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse); + rpc ComputeGasTopUp(ComputeGasTopUpRequest) returns (ComputeGasTopUpResponse); + rpc EnsureGasTopUp(EnsureGasTopUpRequest) returns (EnsureGasTopUpResponse); }