From d46822b9bb49c85a4ef95e1654279209aad0521d Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 25 Dec 2025 11:25:13 +0100 Subject: [PATCH] gas tanking before transaction --- api/gateway/chain/go.mod | 2 +- api/gateway/chain/go.sum | 4 +- .../service/gateway/commands/transfer/fee.go | 6 +- .../gateway/commands/wallet/balance.go | 30 +- .../service/gateway/commands/wallet/create.go | 10 +- .../commands/wallet/onchain_balance.go | 22 +- .../service/gateway/commands/wallet/proto.go | 1 + .../service/gateway/driver/arbitrum/driver.go | 25 + .../internal/service/gateway/driver/driver.go | 1 + .../service/gateway/driver/ethereum/driver.go | 25 + .../service/gateway/driver/evm/evm.go | 258 +++++++--- .../service/gateway/driver/tron/driver.go | 32 ++ .../internal/service/gateway/service_test.go | 53 +- api/gateway/chain/storage/model/wallet.go | 1 + api/payments/orchestrator/config.yml | 4 +- .../internal/server/internal/serverimp.go | 2 + .../service/orchestrator/card_payout.go | 469 ++++++++++++++++-- .../service/orchestrator/card_payout_test.go | 385 ++++++++++++++ .../internal/service/orchestrator/convert.go | 36 ++ .../internal/service/orchestrator/options.go | 3 +- .../orchestrator/storage/model/payment.go | 36 ++ api/proto/gateway/chain/v1/chain.proto | 1 + .../orchestrator/v1/orchestrator.proto | 17 + api/server/interface/api/sresponse/payment.go | 20 - 24 files changed, 1283 insertions(+), 160 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 58d4cc0..498cc8b 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -22,7 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index b7d34cc..99a62fb 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 h1:wCr/SrKzMrtW9wG85ApPfncRr7ajzkRevhsWnCkl2sE= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go index 320f55e..4dfa741 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go @@ -95,9 +95,13 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) } + contextLabel := "erc20_transfer" + if strings.TrimSpace(sourceWallet.ContractAddress) == "" { + contextLabel = "native_transfer" + } resp := &chainv1.EstimateTransferFeeResponse{ NetworkFee: feeMoney, - EstimationContext: "erc20_transfer", + EstimationContext: contextLabel, } return gsresponse.Success(resp) } 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 3d57656..bd2d1bd 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) } - balance, chainErr := onChainWalletBalance(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) @@ -74,37 +74,47 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW } calculatedAt := c.now() - c.persistCachedBalance(ctx, walletRef, balance, calculatedAt) + c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt) return gsresponse.Success(&chainv1.GetWalletBalanceResponse{ - Balance: onChainBalanceToProto(balance, calculatedAt), + Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt), }) } -func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance { - if balance == nil { +func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance { + if balance == nil && native == nil { return nil } - zero := zeroMoney(balance.Currency) + currency := "" + if balance != nil { + currency = balance.Currency + } + zero := zeroMoney(currency) return &chainv1.WalletBalance{ Available: balance, + NativeAvailable: native, PendingInbound: zero, PendingOutbound: zero, CalculatedAt: timestamppb.New(calculatedAt.UTC()), } } -func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) { - if available == nil { +func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) { + if available == nil && nativeAvailable == nil { return } record := &model.WalletBalance{ WalletRef: walletRef, Available: shared.CloneMoney(available), - PendingInbound: zeroMoney(available.Currency), - PendingOutbound: zeroMoney(available.Currency), + NativeAvailable: shared.CloneMoney(nativeAvailable), CalculatedAt: calculatedAt, } + currency := "" + if available != nil { + currency = available.Currency + } + record.PendingInbound = zeroMoney(currency) + record.PendingOutbound = zeroMoney(currency) if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil { c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err)) } diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go index 7d7c252..73adb1a 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go @@ -82,10 +82,12 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C } contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress())) if contractAddress == "" { - contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) - if contractAddress == "" { - c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) - return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) + if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) { + contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) + if contractAddress == "" { + c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) + } } } 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 6c0168d..a938626 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,16 +12,16 @@ import ( "go.uber.org/zap" ) -func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*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, merrors.InvalidArgument("wallet is required") + return nil, nil, merrors.InvalidArgument("wallet is required") } if deps.Networks == nil { - return nil, merrors.Internal("rpc clients not initialised") + return nil, nil, merrors.Internal("rpc clients not initialised") } if deps.Drivers == nil { - return nil, merrors.Internal("chain drivers not configured") + return nil, nil, merrors.Internal("chain drivers not configured") } networkKey := strings.ToLower(strings.TrimSpace(wallet.Network)) @@ -31,7 +31,7 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW zap.String("wallet_ref", wallet.WalletRef), zap.String("network", networkKey), ) - return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey)) + return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey)) } chainDriver, err := deps.Drivers.Driver(networkKey) @@ -41,7 +41,7 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW zap.String("network", networkKey), zap.Error(err), ) - return nil, merrors.InvalidArgument("unsupported chain") + return nil, nil, merrors.InvalidArgument("unsupported chain") } driverDeps := driver.Deps{ @@ -50,5 +50,13 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW KeyManager: deps.KeyManager, RPCTimeout: deps.RPCTimeout, } - return chainDriver.Balance(ctx, driverDeps, network, wallet) + tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet) + if err != nil { + return nil, nil, err + } + nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet) + if err != nil { + return nil, nil, err + } + return tokenBalance, nativeBalance, nil } diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/proto.go b/api/gateway/chain/internal/service/gateway/commands/wallet/proto.go index 16ff0ad..ed5cf1a 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/proto.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/proto.go @@ -58,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance { } return &chainv1.WalletBalance{ Available: shared.CloneMoney(balance.Available), + NativeAvailable: shared.CloneMoney(balance.NativeAvailable), PendingInbound: shared.CloneMoney(balance.PendingInbound), PendingOutbound: shared.CloneMoney(balance.PendingOutbound), CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()), diff --git a/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go index 4c44f19..a625311 100644 --- a/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go @@ -69,6 +69,31 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N return result, err } +func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + d.logger.Debug("native balance request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress) + if err != nil { + d.logger.Warn("native balance failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("native balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { d.logger.Debug("estimate fee request", zap.String("wallet_ref", wallet.WalletRef), diff --git a/api/gateway/chain/internal/service/gateway/driver/driver.go b/api/gateway/chain/internal/service/gateway/driver/driver.go index 13628d3..5fd311b 100644 --- a/api/gateway/chain/internal/service/gateway/driver/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/driver.go @@ -27,6 +27,7 @@ type Driver interface { FormatAddress(address string) (string, error) NormalizeAddress(address string) (string, error) Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) + NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error) diff --git a/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go index 284916f..544846e 100644 --- a/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go @@ -69,6 +69,31 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N return result, err } +func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + d.logger.Debug("native balance request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress) + if err != nil { + d.logger.Warn("native balance failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("native balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { d.logger.Debug("estimate fee request", zap.String("wallet_ref", wallet.WalletRef), diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/evm.go b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go index 841f7b9..d4c5f63 100644 --- a/api/gateway/chain/internal/service/gateway/driver/evm/evm.go +++ b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go @@ -70,6 +70,29 @@ func NormalizeAddress(address string) (string, error) { return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil } +func nativeCurrency(network shared.Network) string { + currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) + if currency == "" { + currency = strings.ToUpper(network.Name) + } + return currency +} + +func parseBaseUnitAmount(amount string) (*big.Int, error) { + trimmed := strings.TrimSpace(amount) + if trimmed == "" { + return nil, merrors.InvalidArgument("amount is required") + } + value, ok := new(big.Int).SetString(trimmed, 10) + if !ok { + return nil, merrors.InvalidArgument("invalid amount") + } + if value.Sign() < 0 { + return nil, merrors.InvalidArgument("amount must be non-negative") + } + return value, nil +} + // Balance fetches ERC20 token balance for the provided address. func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) { logger := deps.Logger @@ -101,7 +124,11 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall } contract := strings.TrimSpace(wallet.ContractAddress) - if contract == "" || !common.IsHexAddress(contract) { + if contract == "" { + logger.Debug("Native balance requested", logFields...) + return NativeBalance(ctx, deps, network, wallet, normalizedAddress) + } + if !common.IsHexAddress(contract) { logger.Warn("Invalid contract address for balance fetch", logFields...) return nil, merrors.InvalidArgument("invalid contract address") } @@ -146,6 +173,64 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil } +// NativeBalance fetches native token balance for the provided address. +func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) { + logger := deps.Logger + registry := deps.Registry + + if registry == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + + normalizedAddress, err := NormalizeAddress(address) + if err != nil { + return nil, err + } + + rpcURL := strings.TrimSpace(network.RPCURL) + logFields := []zap.Field{ + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))), + zap.String("wallet_address", normalizedAddress), + } + if rpcURL == "" { + logger.Warn("Network rpc url is not configured", logFields...) + return nil, merrors.Internal("network rpc url is not configured") + } + + client, err := registry.Client(network.Name) + if err != nil { + logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...) + return nil, err + } + + timeout := deps.RPCTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil) + if err != nil { + logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...) + return nil, err + } + + logger.Info("On-chain native balance fetched", + append(logFields, + zap.String("balance_raw", bal.String()), + )..., + ) + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: bal.String(), + }, nil +} + // EstimateFee estimates ERC20 transfer fees for the given parameters. func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { logger := deps.Logger @@ -165,12 +250,6 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, if rpcURL == "" { return nil, merrors.InvalidArgument("network rpc url not configured") } - if strings.TrimSpace(wallet.ContractAddress) == "" { - return nil, merrors.NotImplemented("native token transfers not supported") - } - if !common.IsHexAddress(wallet.ContractAddress) { - return nil, merrors.InvalidArgument("invalid token contract address") - } if _, err := NormalizeAddress(fromAddress); err != nil { return nil, merrors.InvalidArgument("invalid source wallet address") } @@ -194,10 +273,42 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - tokenAddr := common.HexToAddress(wallet.ContractAddress) + contract := strings.TrimSpace(wallet.ContractAddress) toAddr := common.HexToAddress(destination) fromAddr := common.HexToAddress(fromAddress) + if contract == "" { + amountBase, err := parseBaseUnitAmount(amount.GetAmount()) + if err != nil { + return nil, err + } + gasPrice, err := client.SuggestGasPrice(timeoutCtx) + if err != nil { + return nil, merrors.Internal("failed to suggest gas price: " + err.Error()) + } + callMsg := ethereum.CallMsg{ + From: fromAddr, + To: &toAddr, + GasPrice: gasPrice, + Value: amountBase, + } + gasLimit, err := client.EstimateGas(timeoutCtx, callMsg) + if err != nil { + return nil, merrors.Internal("failed to estimate gas: " + err.Error()) + } + fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) + feeDec := decimal.NewFromBigInt(fee, 0) + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: feeDec.String(), + }, nil + } + if !common.IsHexAddress(contract) { + return nil, merrors.InvalidArgument("invalid token contract address") + } + + tokenAddr := common.HexToAddress(contract) + decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr) if err != nil { logger.Warn("Failed to read token decimals", zap.Error(err)) @@ -233,13 +344,8 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) feeDec := decimal.NewFromBigInt(fee, 0) - currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) - if currency == "" { - currency = strings.ToUpper(network.Name) - } - return &moneyv1.Money{ - Currency: currency, + Currency: nativeCurrency(network), Amount: feeDec.String(), }, nil } @@ -322,66 +428,86 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ chainID := new(big.Int).SetUint64(network.ChainID) - if strings.TrimSpace(transfer.ContractAddress) == "" { - logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef)) - return "", merrors.NotImplemented("executor: native token transfers not yet supported") - } - - if !common.IsHexAddress(transfer.ContractAddress) { - logger.Warn("Invalid token contract address", - zap.String("transfer_ref", transfer.TransferRef), - zap.String("contract", transfer.ContractAddress), - ) - return "", executorInvalid("invalid token contract address " + transfer.ContractAddress) - } - tokenAddress := common.HexToAddress(transfer.ContractAddress) - - decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress) - if err != nil { - logger.Warn("Failed to read token decimals", zap.Error(err), - zap.String("transfer_ref", transfer.TransferRef), - zap.String("contract", transfer.ContractAddress), - ) - return "", err - } - + contract := strings.TrimSpace(transfer.ContractAddress) amount := transfer.NetAmount if amount == nil || strings.TrimSpace(amount.Amount) == "" { logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef)) return "", executorInvalid("transfer missing net amount") } - amountInt, err := toBaseUnits(amount.Amount, decimals) - if err != nil { - logger.Warn("Failed to convert amount to base units", zap.Error(err), - zap.String("transfer_ref", transfer.TransferRef), - zap.String("amount", amount.Amount), - ) - return "", err - } - input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt) - if err != nil { - logger.Warn("Failed to encode transfer call", zap.Error(err), - zap.String("transfer_ref", transfer.TransferRef), - ) - return "", executorInternal("failed to encode transfer call", err) - } + var tx *types.Transaction + if contract == "" { + amountInt, err := parseBaseUnitAmount(amount.Amount) + if err != nil { + logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return "", err + } + callMsg := ethereum.CallMsg{ + From: sourceAddress, + To: &destinationAddr, + GasPrice: gasPrice, + Value: amountInt, + } + gasLimit, err := client.EstimateGas(ctx, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to estimate gas", err) + } + tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil) + } else { + if !common.IsHexAddress(contract) { + logger.Warn("Invalid token contract address", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("contract", contract), + ) + return "", executorInvalid("invalid token contract address " + contract) + } + tokenAddress := common.HexToAddress(contract) - callMsg := ethereum.CallMsg{ - From: sourceAddress, - To: &tokenAddress, - GasPrice: gasPrice, - Data: input, - } - gasLimit, err := client.EstimateGas(ctx, callMsg) - if err != nil { - logger.Warn("Failed to estimate gas", zap.Error(err), - zap.String("transfer_ref", transfer.TransferRef), - ) - return "", executorInternal("failed to estimate gas", err) - } + decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress) + if err != nil { + logger.Warn("Failed to read token decimals", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("contract", contract), + ) + return "", err + } - tx := types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input) + amountInt, err := toBaseUnits(amount.Amount, decimals) + if err != nil { + logger.Warn("Failed to convert amount to base units", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("amount", amount.Amount), + ) + return "", err + } + + input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt) + if err != nil { + logger.Warn("Failed to encode transfer call", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to encode transfer call", err) + } + + callMsg := ethereum.CallMsg{ + From: sourceAddress, + To: &tokenAddress, + GasPrice: gasPrice, + Data: input, + } + gasLimit, err := client.EstimateGas(ctx, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to estimate gas", err) + } + + tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input) + } signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID) if err != nil { diff --git a/api/gateway/chain/internal/service/gateway/driver/tron/driver.go b/api/gateway/chain/internal/service/gateway/driver/tron/driver.go index 8661717..35a8e4e 100644 --- a/api/gateway/chain/internal/service/gateway/driver/tron/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/tron/driver.go @@ -77,6 +77,38 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N return result, err } +func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name)) + rpcAddr, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("Native balance address conversion failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + ) + return nil, err + } + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr) + if err != nil { + d.logger.Warn("Native balance failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + ) + } else if result != nil { + d.logger.Debug("native balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { if wallet == nil { return nil, merrors.InvalidArgument("wallet is required") diff --git a/api/gateway/chain/internal/service/gateway/service_test.go b/api/gateway/chain/internal/service/gateway/service_test.go index c22cc3b..f040da9 100644 --- a/api/gateway/chain/internal/service/gateway/service_test.go +++ b/api/gateway/chain/internal/service/gateway/service_test.go @@ -66,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) { require.Equal(t, 1, repo.wallets.count()) } +func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-native", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, + TokenSymbol: "ETH", + }, + }) + require.NoError(t, err) + require.NotNil(t, resp.GetWallet()) + require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol()) + require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress()) +} + func TestSubmitTransfer_ManagedDestination(t *testing.T) { svc, repo := newTestService(t) ctx := context.Background() @@ -144,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) { require.Equal(t, codes.NotFound, st.Code()) } +func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) { + svc, repo := newTestService(t) + ctx := context.Background() + + createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-balance", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, + TokenSymbol: "USDC", + }, + }) + require.NoError(t, err) + walletRef := createResp.GetWallet().GetWalletRef() + + err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{ + WalletRef: walletRef, + Available: &moneyv1.Money{Currency: "USDC", Amount: "25"}, + NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"}, + CalculatedAt: time.Now().UTC(), + }) + require.NoError(t, err) + + resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef}) + require.NoError(t, err) + require.NotNil(t, resp.GetBalance()) + require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount()) + require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency()) +} + // ---- in-memory storage implementation ---- type inMemoryRepository struct { @@ -531,7 +581,8 @@ func newTestService(t *testing.T) (*Service, *inMemoryRepository) { repo := newInMemoryRepository() logger := zap.NewNop() networks := []shared.Network{{ - Name: "ethereum_mainnet", + Name: "ethereum_mainnet", + NativeToken: "ETH", TokenConfigs: []shared.TokenContract{ {Symbol: "USDC", ContractAddress: "0xusdc"}, }, diff --git a/api/gateway/chain/storage/model/wallet.go b/api/gateway/chain/storage/model/wallet.go index f5236d3..22d8357 100644 --- a/api/gateway/chain/storage/model/wallet.go +++ b/api/gateway/chain/storage/model/wallet.go @@ -47,6 +47,7 @@ type WalletBalance struct { WalletRef string `bson:"walletRef" json:"walletRef"` Available *moneyv1.Money `bson:"available" json:"available"` + NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"` PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"` PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"` CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"` diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index 57f2d60..c3637f0 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -59,8 +59,8 @@ oracle: card_gateways: monetix: - funding_address: "wallet_funding_monetix" - fee_address: "wallet_fee_monetix" + funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8" + fee_wallet_ref: "694c124fd76f9f811ac57134" fee_ledger_accounts: monetix: "ledger:fees:monetix" diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 7d086fa..2390577 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -59,6 +59,7 @@ type clientConfig struct { type cardGatewayRouteConfig struct { FundingAddress string `yaml:"funding_address"` FeeAddress string `yaml:"fee_address"` + FeeWalletRef string `yaml:"fee_wallet_ref"` } func (c clientConfig) address() string { @@ -323,6 +324,7 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or result[trimmedKey] = orchestrator.CardGatewayRoute{ FundingAddress: strings.TrimSpace(route.FundingAddress), FeeAddress: strings.TrimSpace(route.FeeAddress), + FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), } } return result diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go index 65bf7ee..ffe9b2d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go @@ -7,13 +7,21 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.uber.org/zap" ) -const defaultCardGateway = "monetix" +const ( + defaultCardGateway = "monetix" + + stepCodeGasTopUp = "gas_top_up" + stepCodeFundingTransfer = "funding_transfer" + stepCodeCardPayout = "card_payout" + stepCodeFeeTransfer = "fee_transfer" +) func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { if len(s.deps.cardRoutes) == 0 { @@ -54,24 +62,204 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model if err != nil { return err } + sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef) + fundingAddress := strings.TrimSpace(route.FundingAddress) + feeWalletRef := strings.TrimSpace(route.FeeWalletRef) amount := cloneMoney(intent.Amount) - if amount == nil { + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { return merrors.InvalidArgument("card funding: amount is required") } + payoutAmount, err := cardPayoutAmount(payment) + if err != nil { + return err + } + + feeMoney := (*moneyv1.Money)(nil) + if quote != nil { + feeMoney = quote.GetExpectedFeeTotal() + } + if feeMoney == nil && payment.LastQuote != nil { + feeMoney = payment.LastQuote.ExpectedFeeTotal + } + feeDecimal := decimal.Zero + if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { + if strings.TrimSpace(feeMoney.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: fee currency is required") + } + feeDecimal, err = decimalFromMoney(feeMoney) + if err != nil { + return err + } + } + feeRequired := feeDecimal.IsPositive() + + fundingDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, + } + fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount) + if err != nil { + return err + } + + var feeTransferFee *moneyv1.Money + if feeRequired { + if feeWalletRef == "" { + return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists") + } + feeDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, + } + feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney) + if err != nil { + return err + } + } + + requiredGas, 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 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) + if err != nil { + return err + } + } + + plan := ensureExecutionPlan(payment) + var gasStep *model.ExecutionStep + if topUpMoney != nil && topUpAmount.IsPositive() { + gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) + gasStep.Description = "Top up native gas from fee wallet" + gasStep.Amount = cloneMoney(topUpMoney) + gasStep.NetworkFee = cloneMoney(topUpFee) + gasStep.SourceWalletRef = feeWalletRef + gasStep.DestinationRef = sourceWalletRef + } + + fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) + fundStep.Description = "Transfer payout amount to card funding wallet" + fundStep.Amount = cloneMoney(amount) + fundStep.NetworkFee = cloneMoney(fundingFee) + fundStep.SourceWalletRef = sourceWalletRef + fundStep.DestinationRef = fundingAddress + + cardStep := ensureExecutionStep(plan, stepCodeCardPayout) + cardStep.Description = "Submit card payout" + cardStep.Amount = cloneMoney(payoutAmount) + if card := intent.Destination.Card; card != nil { + if masked := strings.TrimSpace(card.MaskedPan); masked != "" { + cardStep.DestinationRef = masked + } + } + + if feeRequired { + step := ensureExecutionStep(plan, stepCodeFeeTransfer) + step.Description = "Transfer fee to fee wallet" + step.Amount = cloneMoney(feeMoney) + step.NetworkFee = cloneMoney(feeTransferFee) + step.SourceWalletRef = sourceWalletRef + step.DestinationRef = feeWalletRef + } + + updateExecutionPlanTotalNetworkFee(plan) + exec := payment.Execution if exec == nil { 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 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()) + } + s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) + } + // Transfer payout amount to funding wallet. fundReq := &chainv1.SubmitTransferRequest{ IdempotencyKey: payment.IdempotencyKey + ":card:fund", OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), + SourceWalletRef: sourceWalletRef, Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)}, + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, }, Amount: amount, Metadata: cloneMetadata(payment.Metadata), @@ -84,42 +272,10 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model } if fundResp != nil && fundResp.GetTransfer() != nil { exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) + fundStep.TransferRef = exec.ChainTransferRef } s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) - feeMoney := quote.GetExpectedFeeTotal() - if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { - if strings.TrimSpace(route.FeeAddress) == "" { - return merrors.InvalidArgument("card funding: fee address is required when fee exists") - } - feeDecimal, err := decimalFromMoney(feeMoney) - if err != nil { - return err - } - if feeDecimal.IsPositive() { - feeReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:fee", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)}, - }, - Amount: feeMoney, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - } - feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq) - if feeErr != nil { - s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef)) - return feeErr - } - if feeResp != nil && feeResp.GetTransfer() != nil { - exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) - } - s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) - } - } - payment.Execution = exec return nil } @@ -133,9 +289,9 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) if card == nil { return merrors.InvalidArgument("card payout: card endpoint is required") } - amount := cloneMoney(intent.Amount) - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return merrors.InvalidArgument("card payout: amount is required") + amount, err := cardPayoutAmount(payment) + if err != nil { + return err } amtDec, err := decimalFromMoney(amount) if err != nil { @@ -193,13 +349,92 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) return merrors.Internal("card payout: missing payout state") } recordCardPayoutState(payment, state) - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} + exec := payment.Execution + if exec == nil { + exec = &model.ExecutionRefs{} } - if payment.Execution.CardPayoutRef == "" { - payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) + if exec.CardPayoutRef == "" { + exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) } - s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef)) + payment.Execution = exec + + plan := ensureExecutionPlan(payment) + if plan != nil { + step := ensureExecutionStep(plan, stepCodeCardPayout) + step.Description = "Submit card payout" + step.Amount = cloneMoney(amount) + if masked := strings.TrimSpace(card.MaskedPan); masked != "" { + step.DestinationRef = masked + } + if exec.CardPayoutRef != "" { + step.TransferRef = exec.CardPayoutRef + } + updateExecutionPlanTotalNetworkFee(plan) + } + + feeMoney := (*moneyv1.Money)(nil) + if payment.LastQuote != nil { + feeMoney = payment.LastQuote.ExpectedFeeTotal + } + if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { + if strings.TrimSpace(feeMoney.GetCurrency()) == "" { + return merrors.InvalidArgument("card payout: fee currency is required") + } + feeDecimal, err := decimalFromMoney(feeMoney) + if err != nil { + return err + } + if feeDecimal.IsPositive() { + if !s.deps.gateway.available() { + s.logger.Warn("card fee aborted: chain gateway unavailable") + return merrors.InvalidArgument("card payout: chain gateway unavailable") + } + sourceWallet := intent.Source.ManagedWallet + if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" { + return merrors.InvalidArgument("card payout: source managed wallet is required") + } + route, err := s.cardRoute(defaultCardGateway) + if err != nil { + return err + } + feeWalletRef := strings.TrimSpace(route.FeeWalletRef) + if feeWalletRef == "" { + return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists") + } + feeReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:fee", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef), + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, + }, + Amount: feeMoney, + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + } + feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq) + if feeErr != nil { + s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef)) + return feeErr + } + if feeResp != nil && feeResp.GetTransfer() != nil { + exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) + } + s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) + + if plan != nil { + step := ensureExecutionStep(plan, stepCodeFeeTransfer) + step.Description = "Transfer fee to fee wallet" + step.Amount = cloneMoney(feeMoney) + step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef) + step.DestinationRef = feeWalletRef + step.TransferRef = exec.FeeTransferRef + updateExecutionPlanTotalNetworkFee(plan) + } + } + } + + s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef)) return nil } @@ -250,3 +485,147 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat // leave as-is for pending/unspecified } } + +func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) { + if payment == nil { + return nil, merrors.InvalidArgument("payment is required") + } + amount := cloneMoney(payment.Intent.Amount) + if payment.LastQuote != nil { + settlement := payment.LastQuote.ExpectedSettlementAmount + if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { + amount = cloneMoney(settlement) + } + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("card payout: amount is required") + } + return amount, nil +} + +func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) { + if !s.deps.gateway.available() { + return nil, merrors.InvalidArgument("chain gateway unavailable") + } + sourceWalletRef = strings.TrimSpace(sourceWalletRef) + if sourceWalletRef == "" { + return nil, merrors.InvalidArgument("source wallet ref is required") + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("amount is required") + } + + resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ + SourceWalletRef: sourceWalletRef, + Destination: destination, + Amount: cloneMoney(amount), + }) + if err != nil { + s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + if resp == nil { + s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + fee := resp.GetNetworkFee() + if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { + s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + return cloneMoney(fee), nil +} + +func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) { + total := decimal.Zero + currency := "" + for _, fee := range fees { + if fee == nil { + continue + } + amount := strings.TrimSpace(fee.GetAmount()) + feeCurrency := strings.TrimSpace(fee.GetCurrency()) + if amount == "" || feeCurrency == "" { + return decimal.Zero, "", merrors.InvalidArgument("network fee is required") + } + value, err := decimalFromMoney(fee) + if err != nil { + return decimal.Zero, "", err + } + if currency == "" { + currency = feeCurrency + } else if !strings.EqualFold(currency, feeCurrency) { + return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch") + } + total = total.Add(value) + } + return total, currency, nil +} + +func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { + if payment == nil { + return nil + } + if payment.ExecutionPlan == nil { + payment.ExecutionPlan = &model.ExecutionPlan{} + } + return payment.ExecutionPlan +} + +func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { + if plan == nil { + return nil + } + code = strings.TrimSpace(code) + if code == "" { + return nil + } + for _, step := range plan.Steps { + if step == nil { + continue + } + if strings.EqualFold(step.Code, code) { + if step.Code == "" { + step.Code = code + } + return step + } + } + step := &model.ExecutionStep{Code: code} + plan.Steps = append(plan.Steps, step) + return step +} + +func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { + if plan == nil { + return + } + total := decimal.Zero + currency := "" + hasFee := false + for _, step := range plan.Steps { + if step == nil || step.NetworkFee == nil { + continue + } + fee := step.NetworkFee + if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { + continue + } + if currency == "" { + currency = strings.TrimSpace(fee.GetCurrency()) + } else if !strings.EqualFold(currency, fee.GetCurrency()) { + continue + } + value, err := decimalFromMoney(fee) + if err != nil { + continue + } + total = total.Add(value) + hasFee = true + } + if !hasFee || currency == "" { + plan.TotalNetworkFee = nil + return + } + plan.TotalNetworkFee = makeMoney(currency, total) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go new file mode 100644 index 0000000..692084f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -0,0 +1,385 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" + mo "github.com/tech/sendico/pkg/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { + ctx := context.Background() + + const ( + sourceWalletRef = "wallet-src" + feeWalletRef = "wallet-fee" + fundingAddress = "0xfunding" + ) + + var estimateCalls []*chainv1.EstimateTransferFeeRequest + var submitCalls []*chainv1.SubmitTransferRequest + + gateway := &chainclient.Fake{ + EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { + estimateCalls = append(estimateCalls, req) + dest := req.GetDestination() + if req.GetSourceWalletRef() == feeWalletRef { + return &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"}, + }, nil + } + if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" { + return &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, + }, nil + } + return &chainv1.EstimateTransferFeeResponse{ + 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"}, + }, + }, nil + }, + SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitCalls = append(submitCalls, req) + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, + }, nil + }, + } + + svc := &Service{ + logger: zap.NewNop(), + deps: serviceDependencies{ + gateway: gatewayDependency{client: gateway}, + cardRoutes: map[string]CardGatewayRoute{ + defaultCardGateway: { + FundingAddress: fundingAddress, + FeeWalletRef: feeWalletRef, + }, + }, + }, + } + + payment := &model.Payment{ + PaymentRef: "pay-1", + IdempotencyKey: "pay-1", + OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()}, + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: sourceWalletRef, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + MaskedPan: "4111", + }, + }, + Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + }, + } + + quote := &orchestratorv1.PaymentQuote{ + ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, + } + + if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil { + t.Fatalf("submitCardFundingTransfers error: %v", err) + } + + if len(estimateCalls) != 3 { + t.Fatalf("expected 3 fee estimates, got %d", len(estimateCalls)) + } + if len(submitCalls) != 2 { + t.Fatalf("expected 2 transfer submissions, 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()) + } + if gasCall.GetDestination().GetManagedWalletRef() != sourceWalletRef { + t.Fatalf("gas top-up destination mismatch: %s", gasCall.GetDestination().GetManagedWalletRef()) + } + 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()) + } + + fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund") + if fundCall.GetDestination().GetExternalAddress() != fundingAddress { + t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress()) + } + if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" { + t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount()) + } + + if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" { + t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution) + } + + plan := payment.ExecutionPlan + if plan == nil { + t.Fatal("expected execution plan to be populated") + } + gasStep := findExecutionStep(t, plan, stepCodeGasTopUp) + if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" { + t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount()) + } + 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") + } + + fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer) + if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" { + t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount()) + } + if fundStep.TransferRef != "pay-1:card:fund" { + t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef) + } + + cardStep := findExecutionStep(t, plan, stepCodeCardPayout) + if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" { + t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) + } + + feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer) + if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" { + t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount()) + } + if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" { + t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount()) + } + if feeStep.TransferRef != "" { + t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef) + } + + if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" { + t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee) + } +} + +func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { + ctx := context.Background() + + const ( + sourceWalletRef = "wallet-src" + feeWalletRef = "wallet-fee" + ) + + var payoutReq *mntxv1.CardPayoutRequest + var submitCalls []*chainv1.SubmitTransferRequest + + gateway := &chainclient.Fake{ + SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitCalls = append(submitCalls, req) + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"}, + }, nil + }, + } + mntx := &mntxclient.Fake{ + CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutReq = req + return &mntxv1.CardPayoutResponse{ + Payout: &mntxv1.CardPayoutState{ + PayoutId: "payout-1", + Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + }, + }, nil + }, + } + + svc := &Service{ + logger: zap.NewNop(), + deps: serviceDependencies{ + gateway: gatewayDependency{client: gateway}, + mntx: mntxDependency{client: mntx}, + cardRoutes: map[string]CardGatewayRoute{ + defaultCardGateway: { + FundingAddress: "0xfunding", + FeeWalletRef: feeWalletRef, + }, + }, + }, + } + + payment := &model.Payment{ + PaymentRef: "pay-2", + IdempotencyKey: "pay-2", + OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()}, + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: sourceWalletRef, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "5536913762657597", + Cardholder: "Stephan", + }, + }, + Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + }, + LastQuote: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"}, + ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, + }, + } + + if err := svc.submitCardPayout(ctx, payment); err != nil { + t.Fatalf("submitCardPayout error: %v", err) + } + + if payoutReq == nil { + t.Fatal("expected card payout request to be sent") + } + if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 { + t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor()) + } + + if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" { + t.Fatalf("expected card payout ref recorded, got %v", payment.Execution) + } + if payment.Execution.FeeTransferRef != "fee-transfer" { + t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution) + } + + if len(submitCalls) != 1 { + t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls)) + } + feeCall := submitCalls[0] + if feeCall.GetSourceWalletRef() != sourceWalletRef { + t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef()) + } + if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef { + t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef()) + } + + plan := payment.ExecutionPlan + if plan == nil { + t.Fatal("expected execution plan to be populated") + } + cardStep := findExecutionStep(t, plan, stepCodeCardPayout) + if cardStep.TransferRef != "payout-1" { + t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef) + } + if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" { + t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) + } + + feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer) + if feeStep.TransferRef != "fee-transfer" { + t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef) + } + if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" { + t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount()) + } +} + +func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { + ctx := context.Background() + + gateway := &chainclient.Fake{ + EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { + return &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, + }, nil + }, + } + + svc := &Service{ + logger: zap.NewNop(), + deps: serviceDependencies{ + gateway: gatewayDependency{client: gateway}, + cardRoutes: map[string]CardGatewayRoute{ + defaultCardGateway: { + FundingAddress: "0xfunding", + }, + }, + }, + } + + payment := &model.Payment{ + PaymentRef: "pay-3", + IdempotencyKey: "pay-3", + OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()}, + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + MaskedPan: "4111", + }, + }, + Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + }, + } + + quote := &orchestratorv1.PaymentQuote{ + ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, + } + + err := svc.submitCardFundingTransfers(ctx, payment, quote) + if err == nil { + t.Fatal("expected error for missing fee wallet ref") + } + if !strings.Contains(err.Error(), "fee wallet ref") { + t.Fatalf("unexpected error: %v", err) + } +} + +func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest { + t.Helper() + for _, call := range calls { + if call.GetIdempotencyKey() == idempotencyKey { + return call + } + } + t.Fatalf("missing submit transfer call for %s", idempotencyKey) + return nil +} + +func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep { + t.Helper() + if plan == nil { + t.Fatal("execution plan is nil") + } + for _, step := range plan.Steps { + if step != nil && strings.EqualFold(step.Code, code) { + return step + } + } + t.Fatalf("missing execution step %s", code) + return nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 41b624e..a690b92 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -125,6 +125,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { FailureReason: src.FailureReason, LastQuote: modelQuoteToProto(src.LastQuote), Execution: protoExecutionFromModel(src.Execution), + ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan), Metadata: cloneMetadata(src.Metadata), } if src.CardPayout != nil { @@ -251,6 +252,41 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution } } +func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep { + if src == nil { + return nil + } + return &orchestratorv1.ExecutionStep{ + Code: src.Code, + Description: src.Description, + Amount: cloneMoney(src.Amount), + NetworkFee: cloneMoney(src.NetworkFee), + SourceWalletRef: src.SourceWalletRef, + DestinationRef: src.DestinationRef, + TransferRef: src.TransferRef, + Metadata: cloneMetadata(src.Metadata), + } +} + +func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan { + if src == nil { + return nil + } + steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps)) + for _, step := range src.Steps { + if protoStep := protoExecutionStepFromModel(step); protoStep != nil { + steps = append(steps, protoStep) + } + } + if len(steps) == 0 { + steps = nil + } + return &orchestratorv1.ExecutionPlan{ + Steps: steps, + TotalNetworkFee: cloneMoney(src.TotalNetworkFee), + } +} + func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote { if src == nil { return nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index fedd9a3..708f6e8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -56,10 +56,11 @@ func (m mntxDependency) available() bool { return m.client != nil } -// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses). +// CardGatewayRoute maps a gateway to its funding and fee destinations. type CardGatewayRoute struct { FundingAddress string FeeAddress string + FeeWalletRef string } // WithFeeEngine wires the fee engine client. diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index d4db40d..510e7ab 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -158,6 +158,24 @@ type ExecutionRefs struct { FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"` } +// ExecutionStep describes a planned or executed payment step for reporting. +type ExecutionStep struct { + Code string `bson:"code,omitempty" json:"code,omitempty"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + Amount *moneyv1.Money `bson:"amount,omitempty" json:"amount,omitempty"` + NetworkFee *moneyv1.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` + DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` + TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` +} + +// ExecutionPlan captures the ordered list of steps to execute a payment. +type ExecutionPlan struct { + Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` + TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` +} + // Payment persists orchestrated payment lifecycle. type Payment struct { storable.Base `bson:",inline" json:",inline"` @@ -171,6 +189,7 @@ type Payment struct { FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` + ExecutionPlan *ExecutionPlan `bson:"executionPlan,omitempty" json:"executionPlan,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"` } @@ -218,6 +237,23 @@ func (p *Payment) Normalize() { p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef) p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef) } + if p.ExecutionPlan != nil { + for _, step := range p.ExecutionPlan.Steps { + if step == nil { + continue + } + step.Code = strings.TrimSpace(step.Code) + step.Description = strings.TrimSpace(step.Description) + step.SourceWalletRef = strings.TrimSpace(step.SourceWalletRef) + step.DestinationRef = strings.TrimSpace(step.DestinationRef) + step.TransferRef = strings.TrimSpace(step.TransferRef) + if step.Metadata != nil { + for k, v := range step.Metadata { + step.Metadata[k] = strings.TrimSpace(v) + } + } + } + } } func normalizeEndpoint(ep *PaymentEndpoint) { diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index b3880b9..44d24bb 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -100,6 +100,7 @@ message WalletBalance { common.money.v1.Money pending_inbound = 2; common.money.v1.Money pending_outbound = 3; google.protobuf.Timestamp calculated_at = 4; + common.money.v1.Money native_available = 5; } message GetWalletBalanceRequest { diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 5f211c0..1f48581 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -141,6 +141,22 @@ message ExecutionRefs { string fee_transfer_ref = 6; } +message ExecutionStep { + string code = 1; + string description = 2; + common.money.v1.Money amount = 3; + common.money.v1.Money network_fee = 4; + string source_wallet_ref = 5; + string destination_ref = 6; + string transfer_ref = 7; + map metadata = 8; +} + +message ExecutionPlan { + repeated ExecutionStep steps = 1; + common.money.v1.Money total_network_fee = 2; +} + // Card payout gateway tracking info. message CardPayout { string payout_ref = 1; @@ -166,6 +182,7 @@ message Payment { google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp updated_at = 11; CardPayout card_payout = 12; + ExecutionPlan execution_plan = 13; } message QuotePaymentRequest { diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 6937675..5ebf122 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -7,7 +7,6 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -20,11 +19,6 @@ type FeeLine struct { Meta map[string]string `json:"meta,omitempty"` } -type NetworkFee struct { - NetworkFee *model.Money `json:"networkFee,omitempty"` - EstimationContext string `json:"estimationContext,omitempty"` -} - type FxQuote struct { QuoteRef string `json:"quoteRef,omitempty"` BaseCurrency string `json:"baseCurrency,omitempty"` @@ -45,7 +39,6 @@ type PaymentQuote struct { ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"` ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"` FeeLines []FeeLine `json:"feeLines,omitempty"` - NetworkFee *NetworkFee `json:"networkFee,omitempty"` FxQuote *FxQuote `json:"fxQuote,omitempty"` } @@ -53,7 +46,6 @@ type PaymentQuoteAggregate struct { DebitAmounts []*model.Money `json:"debitAmounts,omitempty"` ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"` ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"` - NetworkFeeTotals []*model.Money `json:"networkFeeTotals,omitempty"` } type PaymentQuotes struct { @@ -146,16 +138,6 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine { return result } -func toNetworkFee(n *chainv1.EstimateTransferFeeResponse) *NetworkFee { - if n == nil { - return nil - } - return &NetworkFee{ - NetworkFee: toMoney(n.GetNetworkFee()), - EstimationContext: n.GetEstimationContext(), - } -} - func toFxQuote(q *oraclev1.Quote) *FxQuote { if q == nil { return nil @@ -192,7 +174,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote { ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), FeeLines: toFeeLines(q.GetFeeLines()), - NetworkFee: toNetworkFee(q.GetNetworkFee()), FxQuote: toFxQuote(q.GetFxQuote()), } } @@ -205,7 +186,6 @@ func toPaymentQuoteAggregate(q *orchestratorv1.PaymentQuoteAggregate) *PaymentQu DebitAmounts: toMoneyList(q.GetDebitAmounts()), ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()), ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()), - NetworkFeeTotals: toMoneyList(q.GetNetworkFeeTotals()), } }