Merge pull request 'gas tanking before transaction' (#158) from tron-157 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
@@ -22,7 +22,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM=
|
||||||
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/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
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/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
|||||||
@@ -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)
|
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{
|
resp := &chainv1.EstimateTransferFeeResponse{
|
||||||
NetworkFee: feeMoney,
|
NetworkFee: feeMoney,
|
||||||
EstimationContext: "erc20_transfer",
|
EstimationContext: contextLabel,
|
||||||
}
|
}
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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 {
|
if chainErr != nil {
|
||||||
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
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)
|
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()
|
calculatedAt := c.now()
|
||||||
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
|
||||||
|
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||||
Balance: onChainBalanceToProto(balance, calculatedAt),
|
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||||
if balance == nil {
|
if balance == nil && native == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zero := zeroMoney(balance.Currency)
|
currency := ""
|
||||||
|
if balance != nil {
|
||||||
|
currency = balance.Currency
|
||||||
|
}
|
||||||
|
zero := zeroMoney(currency)
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: balance,
|
Available: balance,
|
||||||
|
NativeAvailable: native,
|
||||||
PendingInbound: zero,
|
PendingInbound: zero,
|
||||||
PendingOutbound: zero,
|
PendingOutbound: zero,
|
||||||
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
|
||||||
if available == nil {
|
if available == nil && nativeAvailable == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record := &model.WalletBalance{
|
record := &model.WalletBalance{
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
Available: shared.CloneMoney(available),
|
Available: shared.CloneMoney(available),
|
||||||
PendingInbound: zeroMoney(available.Currency),
|
NativeAvailable: shared.CloneMoney(nativeAvailable),
|
||||||
PendingOutbound: zeroMoney(available.Currency),
|
|
||||||
CalculatedAt: calculatedAt,
|
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 {
|
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))
|
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,10 +82,12 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
}
|
}
|
||||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||||
if contractAddress == "" {
|
if contractAddress == "" {
|
||||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
|
||||||
if contractAddress == "" {
|
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
if contractAddress == "" {
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ import (
|
|||||||
"go.uber.org/zap"
|
"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
|
logger := deps.Logger
|
||||||
if wallet == nil {
|
if wallet == nil {
|
||||||
return nil, merrors.InvalidArgument("wallet is required")
|
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||||
}
|
}
|
||||||
if deps.Networks == nil {
|
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 {
|
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))
|
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("wallet_ref", wallet.WalletRef),
|
||||||
zap.String("network", networkKey),
|
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)
|
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.String("network", networkKey),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return nil, merrors.InvalidArgument("unsupported chain")
|
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||||
}
|
}
|
||||||
|
|
||||||
driverDeps := driver.Deps{
|
driverDeps := driver.Deps{
|
||||||
@@ -50,5 +50,13 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW
|
|||||||
KeyManager: deps.KeyManager,
|
KeyManager: deps.KeyManager,
|
||||||
RPCTimeout: deps.RPCTimeout,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
|
|||||||
}
|
}
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: shared.CloneMoney(balance.Available),
|
Available: shared.CloneMoney(balance.Available),
|
||||||
|
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
|
||||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
|||||||
return result, err
|
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) {
|
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",
|
d.logger.Debug("estimate fee request",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Driver interface {
|
|||||||
FormatAddress(address string) (string, error)
|
FormatAddress(address string) (string, error)
|
||||||
NormalizeAddress(address string) (string, error)
|
NormalizeAddress(address string) (string, error)
|
||||||
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, 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)
|
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)
|
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)
|
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
|||||||
return result, err
|
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) {
|
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",
|
d.logger.Debug("estimate fee request",
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ func NormalizeAddress(address string) (string, error) {
|
|||||||
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
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.
|
// 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) {
|
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||||
logger := deps.Logger
|
logger := deps.Logger
|
||||||
@@ -101,7 +124,11 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
|
|||||||
}
|
}
|
||||||
|
|
||||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
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...)
|
logger.Warn("Invalid contract address for balance fetch", logFields...)
|
||||||
return nil, merrors.InvalidArgument("invalid contract address")
|
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
|
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.
|
// 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) {
|
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
|
logger := deps.Logger
|
||||||
@@ -165,12 +250,6 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
|||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
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 {
|
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
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)
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||||
toAddr := common.HexToAddress(destination)
|
toAddr := common.HexToAddress(destination)
|
||||||
fromAddr := common.HexToAddress(fromAddress)
|
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)
|
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to read token decimals", zap.Error(err))
|
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))
|
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||||
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
|
||||||
if currency == "" {
|
|
||||||
currency = strings.ToUpper(network.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &moneyv1.Money{
|
return &moneyv1.Money{
|
||||||
Currency: currency,
|
Currency: nativeCurrency(network),
|
||||||
Amount: feeDec.String(),
|
Amount: feeDec.String(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -322,66 +428,86 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
|||||||
|
|
||||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
contract := 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
|
|
||||||
}
|
|
||||||
|
|
||||||
amount := transfer.NetAmount
|
amount := transfer.NetAmount
|
||||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||||
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return "", executorInvalid("transfer missing net amount")
|
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)
|
var tx *types.Transaction
|
||||||
if err != nil {
|
if contract == "" {
|
||||||
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
amountInt, err := parseBaseUnitAmount(amount.Amount)
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
if err != nil {
|
||||||
)
|
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return "", executorInternal("failed to encode transfer call", err)
|
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{
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
From: sourceAddress,
|
if err != nil {
|
||||||
To: &tokenAddress,
|
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||||
GasPrice: gasPrice,
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
Data: input,
|
zap.String("contract", contract),
|
||||||
}
|
)
|
||||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
return "", err
|
||||||
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)
|
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)
|
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -77,6 +77,38 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
|||||||
return result, err
|
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) {
|
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 {
|
if wallet == nil {
|
||||||
return nil, merrors.InvalidArgument("wallet is required")
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
|||||||
@@ -66,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
|||||||
require.Equal(t, 1, repo.wallets.count())
|
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) {
|
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||||
svc, repo := newTestService(t)
|
svc, repo := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -144,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
|
|||||||
require.Equal(t, codes.NotFound, st.Code())
|
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 ----
|
// ---- in-memory storage implementation ----
|
||||||
|
|
||||||
type inMemoryRepository struct {
|
type inMemoryRepository struct {
|
||||||
@@ -531,7 +581,8 @@ func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
|||||||
repo := newInMemoryRepository()
|
repo := newInMemoryRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
networks := []shared.Network{{
|
networks := []shared.Network{{
|
||||||
Name: "ethereum_mainnet",
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
TokenConfigs: []shared.TokenContract{
|
TokenConfigs: []shared.TokenContract{
|
||||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type WalletBalance struct {
|
|||||||
|
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
Available *moneyv1.Money `bson:"available" json:"available"`
|
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"`
|
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
||||||
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
||||||
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ oracle:
|
|||||||
|
|
||||||
card_gateways:
|
card_gateways:
|
||||||
monetix:
|
monetix:
|
||||||
funding_address: "wallet_funding_monetix"
|
funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8"
|
||||||
fee_address: "wallet_fee_monetix"
|
fee_wallet_ref: "694c124fd76f9f811ac57134"
|
||||||
|
|
||||||
fee_ledger_accounts:
|
fee_ledger_accounts:
|
||||||
monetix: "ledger:fees:monetix"
|
monetix: "ledger:fees:monetix"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type clientConfig struct {
|
|||||||
type cardGatewayRouteConfig struct {
|
type cardGatewayRouteConfig struct {
|
||||||
FundingAddress string `yaml:"funding_address"`
|
FundingAddress string `yaml:"funding_address"`
|
||||||
FeeAddress string `yaml:"fee_address"`
|
FeeAddress string `yaml:"fee_address"`
|
||||||
|
FeeWalletRef string `yaml:"fee_wallet_ref"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c clientConfig) address() string {
|
func (c clientConfig) address() string {
|
||||||
@@ -323,6 +324,7 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
|
|||||||
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
||||||
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
||||||
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
||||||
|
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"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"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
"go.uber.org/zap"
|
"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) {
|
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
|
||||||
if len(s.deps.cardRoutes) == 0 {
|
if len(s.deps.cardRoutes) == 0 {
|
||||||
@@ -54,24 +62,204 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
|
||||||
|
fundingAddress := strings.TrimSpace(route.FundingAddress)
|
||||||
|
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||||
|
|
||||||
amount := cloneMoney(intent.Amount)
|
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")
|
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
|
exec := payment.Execution
|
||||||
if exec == nil {
|
if exec == nil {
|
||||||
exec = &model.ExecutionRefs{}
|
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.
|
// Transfer payout amount to funding wallet.
|
||||||
fundReq := &chainv1.SubmitTransferRequest{
|
fundReq := &chainv1.SubmitTransferRequest{
|
||||||
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
|
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
|
||||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||||
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
|
SourceWalletRef: sourceWalletRef,
|
||||||
Destination: &chainv1.TransferDestination{
|
Destination: &chainv1.TransferDestination{
|
||||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
|
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||||
},
|
},
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Metadata: cloneMetadata(payment.Metadata),
|
Metadata: cloneMetadata(payment.Metadata),
|
||||||
@@ -84,42 +272,10 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
|
|||||||
}
|
}
|
||||||
if fundResp != nil && fundResp.GetTransfer() != nil {
|
if fundResp != nil && fundResp.GetTransfer() != nil {
|
||||||
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
|
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))
|
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
|
payment.Execution = exec
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -133,9 +289,9 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
|||||||
if card == nil {
|
if card == nil {
|
||||||
return merrors.InvalidArgument("card payout: card endpoint is required")
|
return merrors.InvalidArgument("card payout: card endpoint is required")
|
||||||
}
|
}
|
||||||
amount := cloneMoney(intent.Amount)
|
amount, err := cardPayoutAmount(payment)
|
||||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
if err != nil {
|
||||||
return merrors.InvalidArgument("card payout: amount is required")
|
return err
|
||||||
}
|
}
|
||||||
amtDec, err := decimalFromMoney(amount)
|
amtDec, err := decimalFromMoney(amount)
|
||||||
if err != nil {
|
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")
|
return merrors.Internal("card payout: missing payout state")
|
||||||
}
|
}
|
||||||
recordCardPayoutState(payment, state)
|
recordCardPayoutState(payment, state)
|
||||||
if payment.Execution == nil {
|
exec := payment.Execution
|
||||||
payment.Execution = &model.ExecutionRefs{}
|
if exec == nil {
|
||||||
|
exec = &model.ExecutionRefs{}
|
||||||
}
|
}
|
||||||
if payment.Execution.CardPayoutRef == "" {
|
if exec.CardPayoutRef == "" {
|
||||||
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -250,3 +485,147 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat
|
|||||||
// leave as-is for pending/unspecified
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
|||||||
FailureReason: src.FailureReason,
|
FailureReason: src.FailureReason,
|
||||||
LastQuote: modelQuoteToProto(src.LastQuote),
|
LastQuote: modelQuoteToProto(src.LastQuote),
|
||||||
Execution: protoExecutionFromModel(src.Execution),
|
Execution: protoExecutionFromModel(src.Execution),
|
||||||
|
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
|
||||||
Metadata: cloneMetadata(src.Metadata),
|
Metadata: cloneMetadata(src.Metadata),
|
||||||
}
|
}
|
||||||
if src.CardPayout != nil {
|
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 {
|
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ func (m mntxDependency) available() bool {
|
|||||||
return m.client != nil
|
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 {
|
type CardGatewayRoute struct {
|
||||||
FundingAddress string
|
FundingAddress string
|
||||||
FeeAddress string
|
FeeAddress string
|
||||||
|
FeeWalletRef string
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithFeeEngine wires the fee engine client.
|
// WithFeeEngine wires the fee engine client.
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ type ExecutionRefs struct {
|
|||||||
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
|
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.
|
// Payment persists orchestrated payment lifecycle.
|
||||||
type Payment struct {
|
type Payment struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
@@ -171,6 +189,7 @@ type Payment struct {
|
|||||||
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
||||||
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
|
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
|
||||||
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,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"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,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.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef)
|
||||||
p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef)
|
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) {
|
func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ message WalletBalance {
|
|||||||
common.money.v1.Money pending_inbound = 2;
|
common.money.v1.Money pending_inbound = 2;
|
||||||
common.money.v1.Money pending_outbound = 3;
|
common.money.v1.Money pending_outbound = 3;
|
||||||
google.protobuf.Timestamp calculated_at = 4;
|
google.protobuf.Timestamp calculated_at = 4;
|
||||||
|
common.money.v1.Money native_available = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetWalletBalanceRequest {
|
message GetWalletBalanceRequest {
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ message ExecutionRefs {
|
|||||||
string fee_transfer_ref = 6;
|
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<string, string> metadata = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecutionPlan {
|
||||||
|
repeated ExecutionStep steps = 1;
|
||||||
|
common.money.v1.Money total_network_fee = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// Card payout gateway tracking info.
|
// Card payout gateway tracking info.
|
||||||
message CardPayout {
|
message CardPayout {
|
||||||
string payout_ref = 1;
|
string payout_ref = 1;
|
||||||
@@ -166,6 +182,7 @@ message Payment {
|
|||||||
google.protobuf.Timestamp created_at = 10;
|
google.protobuf.Timestamp created_at = 10;
|
||||||
google.protobuf.Timestamp updated_at = 11;
|
google.protobuf.Timestamp updated_at = 11;
|
||||||
CardPayout card_payout = 12;
|
CardPayout card_payout = 12;
|
||||||
|
ExecutionPlan execution_plan = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QuotePaymentRequest {
|
message QuotePaymentRequest {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
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"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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"`
|
Meta map[string]string `json:"meta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetworkFee struct {
|
|
||||||
NetworkFee *model.Money `json:"networkFee,omitempty"`
|
|
||||||
EstimationContext string `json:"estimationContext,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FxQuote struct {
|
type FxQuote struct {
|
||||||
QuoteRef string `json:"quoteRef,omitempty"`
|
QuoteRef string `json:"quoteRef,omitempty"`
|
||||||
BaseCurrency string `json:"baseCurrency,omitempty"`
|
BaseCurrency string `json:"baseCurrency,omitempty"`
|
||||||
@@ -45,7 +39,6 @@ type PaymentQuote struct {
|
|||||||
ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
|
ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
|
||||||
ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
|
ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
|
||||||
FeeLines []FeeLine `json:"feeLines,omitempty"`
|
FeeLines []FeeLine `json:"feeLines,omitempty"`
|
||||||
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
|
|
||||||
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +46,6 @@ type PaymentQuoteAggregate struct {
|
|||||||
DebitAmounts []*model.Money `json:"debitAmounts,omitempty"`
|
DebitAmounts []*model.Money `json:"debitAmounts,omitempty"`
|
||||||
ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"`
|
ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"`
|
||||||
ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"`
|
ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"`
|
||||||
NetworkFeeTotals []*model.Money `json:"networkFeeTotals,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentQuotes struct {
|
type PaymentQuotes struct {
|
||||||
@@ -146,16 +138,6 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
|
|||||||
return result
|
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 {
|
func toFxQuote(q *oraclev1.Quote) *FxQuote {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -192,7 +174,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
|
|||||||
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
|
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
|
||||||
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
|
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
|
||||||
FeeLines: toFeeLines(q.GetFeeLines()),
|
FeeLines: toFeeLines(q.GetFeeLines()),
|
||||||
NetworkFee: toNetworkFee(q.GetNetworkFee()),
|
|
||||||
FxQuote: toFxQuote(q.GetFxQuote()),
|
FxQuote: toFxQuote(q.GetFxQuote()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +186,6 @@ func toPaymentQuoteAggregate(q *orchestratorv1.PaymentQuoteAggregate) *PaymentQu
|
|||||||
DebitAmounts: toMoneyList(q.GetDebitAmounts()),
|
DebitAmounts: toMoneyList(q.GetDebitAmounts()),
|
||||||
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
|
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
|
||||||
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
|
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
|
||||||
NetworkFeeTotals: toMoneyList(q.GetNetworkFeeTotals()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user