Gas topup limits
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user