Compare commits
37 Commits
e6626600cc
...
SEND017
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddd7718c2 | ||
| b96babdfd4 | |||
| 69fdbf4e95 | |||
|
|
d32b2aa959 | ||
|
|
be10839e3a | ||
| d530af43a1 | |||
|
|
aa673fb26d | ||
| d978e24a9d | |||
|
|
31d93e5113 | ||
| f02f3449f3 | |||
|
|
d46822b9bb | ||
| 0505b2314e | |||
|
|
407e704352 | ||
| 4251dfb2c6 | |||
|
|
e0820c47c2 | ||
| 68b82cbca2 | |||
|
|
9e6d530385 | ||
| 5836292adb | |||
| 0c6229331f | |||
| 8cb6a64f2b | |||
|
|
4453dab366 | ||
|
|
512f25f74f | ||
|
|
43020f3eb6 | ||
| 964e90767d | |||
|
|
03cd2f4784 | ||
| 2d735aa7f5 | |||
|
|
342dd5328f | ||
| 915ed66b08 | |||
|
|
fe73b3078a | ||
| 76204822e7 | |||
|
|
77c205f9b2 | ||
| 6a29dc8907 | |||
|
|
8f1f279792 | ||
| 1f0b54d590 | |||
|
|
cefb9706f9 | ||
| 79b7899658 | |||
|
|
c941319c4e |
@@ -24,6 +24,8 @@ type Client interface {
|
|||||||
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ type grpcGatewayClient interface {
|
|||||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainGatewayClient struct {
|
type chainGatewayClient struct {
|
||||||
@@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
|
|||||||
return c.client.EstimateTransferFee(ctx, req)
|
return c.client.EstimateTransferFee(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.ComputeGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.EnsureGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.cfg.CallTimeout
|
timeout := c.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type Fake struct {
|
|||||||
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
CloseFn func() error
|
CloseFn func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
|
|||||||
return &chainv1.EstimateTransferFeeResponse{}, nil
|
return &chainv1.EstimateTransferFeeResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
if f.ComputeGasTopUpFn != nil {
|
||||||
|
return f.ComputeGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.ComputeGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
if f.EnsureGasTopUpFn != nil {
|
||||||
|
return f.EnsureGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error {
|
func (f *Fake) Close() error {
|
||||||
if f.CloseFn != nil {
|
if f.CloseFn != nil {
|
||||||
return f.CloseFn()
|
return f.CloseFn()
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ chains:
|
|||||||
chain_id: 728126428 # 0x2b6653dc
|
chain_id: 728126428 # 0x2b6653dc
|
||||||
native_token: TRX
|
native_token: TRX
|
||||||
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||||
|
gas_topup_policy:
|
||||||
|
buffer_percent: 0.10
|
||||||
|
min_native_balance_trx: 10
|
||||||
|
rounding_unit_trx: 1
|
||||||
|
max_topup_trx: 100
|
||||||
tokens:
|
tokens:
|
||||||
- symbol: USDT
|
- symbol: USDT
|
||||||
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
@@ -60,3 +65,4 @@ key_management:
|
|||||||
|
|
||||||
cache:
|
cache:
|
||||||
wallet_balance_ttl_seconds: 120
|
wallet_balance_ttl_seconds: 120
|
||||||
|
rpc_request_timeout_seconds: 15
|
||||||
|
|||||||
@@ -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-20251222215617-2e6965a531ff // 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-20251222215617-2e6965a531ff h1:dkcn0B/pE1RTOeW9MB/fCxpKq0QaeWE6LkCmzURNE4g=
|
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-20251222215617-2e6965a531ff/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=
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
||||||
@@ -31,6 +34,8 @@ type Imp struct {
|
|||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
|
||||||
|
rpcClients *rpcclient.Clients
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -42,11 +47,12 @@ type config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type chainConfig struct {
|
type chainConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||||
ChainID uint64 `yaml:"chain_id"`
|
ChainID uint64 `yaml:"chain_id"`
|
||||||
NativeToken string `yaml:"native_token"`
|
NativeToken string `yaml:"native_token"`
|
||||||
Tokens []tokenConfig `yaml:"tokens"`
|
Tokens []tokenConfig `yaml:"tokens"`
|
||||||
|
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceWalletConfig struct {
|
type serviceWalletConfig struct {
|
||||||
@@ -62,6 +68,19 @@ type tokenConfig struct {
|
|||||||
ContractEnv string `yaml:"contract_env"`
|
ContractEnv string `yaml:"contract_env"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gasTopUpPolicyConfig struct {
|
||||||
|
gasTopUpRuleConfig `yaml:",inline"`
|
||||||
|
Native *gasTopUpRuleConfig `yaml:"native"`
|
||||||
|
Contract *gasTopUpRuleConfig `yaml:"contract"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gasTopUpRuleConfig struct {
|
||||||
|
BufferPercent float64 `yaml:"buffer_percent"`
|
||||||
|
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
|
||||||
|
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
|
||||||
|
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
|
||||||
|
}
|
||||||
|
|
||||||
// Create initialises the chain gateway server implementation.
|
// Create initialises the chain gateway server implementation.
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
return &Imp{
|
return &Imp{
|
||||||
@@ -85,6 +104,9 @@ func (i *Imp) Shutdown() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
|
if i.rpcClients != nil {
|
||||||
|
i.rpcClients.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
@@ -104,19 +126,29 @@ func (i *Imp) Start() error {
|
|||||||
i.logger.Error("invalid chain network configuration", zap.Error(err))
|
i.logger.Error("invalid chain network configuration", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.rpcClients = rpcClients
|
||||||
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||||
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
|
||||||
opts := []gatewayservice.Option{
|
opts := []gatewayservice.Option{
|
||||||
gatewayservice.WithNetworks(networkConfigs),
|
gatewayservice.WithNetworks(networkConfigs),
|
||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithRPCClients(rpcClients),
|
||||||
|
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||||
gatewayservice.WithSettings(cfg.Settings),
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
@@ -200,17 +232,86 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, gatewayshared.Network{
|
result = append(result, gatewayshared.Network{
|
||||||
Name: chain.Name,
|
Name: chain.Name,
|
||||||
RPCURL: rpcURL,
|
RPCURL: rpcURL,
|
||||||
ChainID: chain.ChainID,
|
ChainID: chain.ChainID,
|
||||||
NativeToken: chain.NativeToken,
|
NativeToken: chain.NativeToken,
|
||||||
TokenConfigs: contracts,
|
TokenConfigs: contracts,
|
||||||
|
GasTopUpPolicy: gasPolicy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !defaultSet {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &gatewayshared.GasTopUpPolicy{
|
||||||
|
Default: defaultRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Native != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Native = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Contract != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Contract = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
|
||||||
|
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, false, nil
|
||||||
|
}
|
||||||
|
if cfg.BufferPercent < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MinNativeBalanceTRX < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.RoundingUnitTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MaxTopUpTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
return gatewayshared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||||
address := strings.TrimSpace(cfg.Address)
|
address := strings.TrimSpace(cfg.Address)
|
||||||
if address == "" && cfg.AddressEnv != "" {
|
if address == "" && cfg.AddressEnv != "" {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Registry struct {
|
|||||||
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
||||||
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
||||||
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
||||||
|
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
|
||||||
|
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistryDeps struct {
|
type RegistryDeps struct {
|
||||||
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
|
|||||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||||
|
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
|
||||||
|
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package transfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -11,9 +14,11 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
deps.Logger.Warn("destination external address missing")
|
deps.Logger.Warn("destination external address missing")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
}
|
}
|
||||||
|
if deps.Drivers == nil {
|
||||||
|
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
|
||||||
|
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
normalized, err := chainDriver.NormalizeAddress(external)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("invalid external address", zap.Error(err))
|
||||||
|
return model.TransferDestination{}, err
|
||||||
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
ExternalAddress: strings.ToLower(external),
|
ExternalAddress: normalized,
|
||||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
|
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ package transfer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -63,11 +53,20 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
}
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
}
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
|
||||||
|
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
}
|
||||||
|
|
||||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,170 +78,30 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
|
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
|
driverDeps := driver.Deps{
|
||||||
|
Logger: c.deps.Logger,
|
||||||
|
Registry: c.deps.Networks,
|
||||||
|
RPCTimeout: c.deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
||||||
if rpcURL == "" {
|
|
||||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
|
||||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.DepositAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(destination) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid destination address")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
|
||||||
toAddr := common.HexToAddress(destination)
|
|
||||||
fromAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to read token decimals", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
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: &tokenAddr,
|
|
||||||
GasPrice: gasPrice,
|
|
||||||
Data: input,
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
|
||||||
if currency == "" {
|
|
||||||
currency = strings.ToUpper(network.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: currency,
|
|
||||||
Amount: feeDec.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
callData, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{
|
|
||||||
To: &token,
|
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", output)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("decimals call returned no data")
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, merrors.Internal("decimals call returned unexpected type")
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
|
||||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
|
||||||
}
|
|
||||||
if value.IsNegative() {
|
|
||||||
return nil, merrors.InvalidArgument("amount must be positive")
|
|
||||||
}
|
|
||||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
|
||||||
scaled := value.Mul(multiplier)
|
|
||||||
if !scaled.Equal(scaled.Truncate(0)) {
|
|
||||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
|
||||||
}
|
|
||||||
return scaled.BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20TransferABI = `
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [],
|
|
||||||
"name": "decimals",
|
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": false,
|
|
||||||
"inputs": [
|
|
||||||
{ "name": "_to", "type": "address" },
|
|
||||||
{ "name": "_value", "type": "uint256" }
|
|
||||||
],
|
|
||||||
"name": "transfer",
|
|
||||||
"outputs": [{ "name": "", "type": "bool" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "nonpayable",
|
|
||||||
"type": "function"
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type computeGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
|
||||||
|
return &computeGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
c.deps.Logger.Warn("wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ensureGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
|
||||||
|
return &ensureGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
c.deps.Logger.Warn("idempotency key missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
|
}
|
||||||
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
if organizationRef == "" {
|
||||||
|
c.deps.Logger.Warn("organization ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
if sourceWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("source wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
|
||||||
|
if targetWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("target wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
|
||||||
|
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: nil,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReq := &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
|
||||||
|
},
|
||||||
|
Amount: topUp,
|
||||||
|
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||||
|
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||||
|
}
|
||||||
|
|
||||||
|
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
|
||||||
|
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
submitResp, err := submitResponder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
Transfer: submitResp.GetTransfer(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
|
||||||
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
|
estimatedFee = shared.CloneMoney(estimatedFee)
|
||||||
|
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
|
||||||
|
networkCfg, ok := deps.Networks.Network(networkKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(networkKey, "tron") {
|
||||||
|
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if networkCfg.GasTopUpPolicy != nil {
|
||||||
|
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, capHit, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, false, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if walletModel == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
walletDeps := wallet.Deps{
|
||||||
|
Logger: deps.Logger.Named("wallet"),
|
||||||
|
Drivers: deps.Drivers,
|
||||||
|
Networks: deps.Networks,
|
||||||
|
KeyManager: nil,
|
||||||
|
Storage: deps.Storage,
|
||||||
|
Clock: deps.Clock,
|
||||||
|
BalanceCacheTTL: 0,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
EnsureRepository: deps.EnsureRepository,
|
||||||
|
}
|
||||||
|
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is unavailable")
|
||||||
|
}
|
||||||
|
return nativeBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is required")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
|
||||||
|
return nil, merrors.InvalidArgument("native balance currency mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
required := estimated.Sub(current)
|
||||||
|
if !required.IsPositive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
|
||||||
|
Amount: required.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.String("estimated_total_fee", amountString(estimatedFee)),
|
||||||
|
zap.String("current_native_balance", amountString(nativeBalance)),
|
||||||
|
zap.String("topup_amount", amountString(topUp)),
|
||||||
|
zap.Bool("cap_hit", capHit),
|
||||||
|
}
|
||||||
|
if walletModel != nil {
|
||||||
|
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
|
||||||
|
}
|
||||||
|
if decision != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
|
||||||
|
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
|
||||||
|
zap.String("required_trx", decision.RequiredTRX.String()),
|
||||||
|
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
|
||||||
|
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
|
||||||
|
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
|
||||||
|
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
|
||||||
|
zap.String("topup_trx", decision.TopUpTRX.String()),
|
||||||
|
zap.String("operation_type", decision.OperationType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.Info("gas top-up decision", fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountString(m *moneyv1.Money) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
if amount == "" && currency == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
return amount + " " + currency
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
}
|
}
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,20 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
networkCfg, ok := c.deps.Networks[chainKey]
|
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("chain drivers missing", zap.String("chain", chainKey))
|
||||||
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(chainKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
|
}
|
||||||
|
|
||||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
if tokenSymbol == "" {
|
if tokenSymbol == "" {
|
||||||
@@ -73,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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +106,11 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("key manager returned empty address")
|
c.deps.Logger.Warn("key manager returned empty address")
|
||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
}
|
}
|
||||||
|
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := shared.CloneMetadata(req.GetMetadata())
|
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||||
desc := req.GetDescribable()
|
desc := req.GetDescribable()
|
||||||
@@ -128,7 +144,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
Network: chainKey,
|
Network: chainKey,
|
||||||
TokenSymbol: tokenSymbol,
|
TokenSymbol: tokenSymbol,
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
DepositAddress: depositAddress,
|
||||||
KeyReference: keyInfo.KeyID,
|
KeyReference: keyInfo.KeyID,
|
||||||
Status: model.ManagedWalletStatusActive,
|
Status: model.ManagedWalletStatusActive,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -13,11 +14,13 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
BalanceCacheTTL time.Duration
|
BalanceCacheTTL time.Duration
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,153 +2,61 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math/big"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
"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 {
|
||||||
|
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if deps.Networks == nil {
|
||||||
|
return nil, nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if deps.Drivers == nil {
|
||||||
|
return nil, nil, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||||
network := deps.Networks[networkKey]
|
network, ok := deps.Networks.Network(networkKey)
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
||||||
|
|
||||||
logFields := []zap.Field{
|
|
||||||
zap.String("wallet_ref", wallet.WalletRef),
|
|
||||||
zap.String("network", networkKey),
|
|
||||||
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
|
|
||||||
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
|
|
||||||
zap.String("wallet_address", strings.ToLower(strings.TrimSpace(wallet.DepositAddress))),
|
|
||||||
}
|
|
||||||
|
|
||||||
if rpcURL == "" {
|
|
||||||
logger.Warn("network rpc url is not configured", logFields...)
|
|
||||||
return nil, merrors.Internal("network rpc url is not configured")
|
|
||||||
}
|
|
||||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
|
||||||
if contract == "" || !common.IsHexAddress(contract) {
|
|
||||||
logger.Warn("invalid contract address for balance fetch", logFields...)
|
|
||||||
return nil, merrors.InvalidArgument("invalid contract address")
|
|
||||||
}
|
|
||||||
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
|
|
||||||
logger.Warn("invalid wallet address for balance fetch", logFields...)
|
|
||||||
return nil, merrors.InvalidArgument("invalid wallet address")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("fetching on-chain wallet balance", logFields...)
|
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to connect rpc", append(logFields, zap.Error(err))...)
|
|
||||||
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to parse erc20 abi", append(logFields, zap.Error(err))...)
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(contract)
|
|
||||||
walletAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
logger.Debug("calling token decimals", logFields...)
|
|
||||||
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("token decimals call failed", append(logFields, zap.Error(err))...)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
|
|
||||||
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
|
||||||
logger.Info("on-chain wallet balance fetched",
|
|
||||||
append(logFields,
|
|
||||||
zap.Uint8("decimals", decimals),
|
|
||||||
zap.String("balance_raw", bal.String()),
|
|
||||||
zap.String("balance", dec.String()),
|
|
||||||
)...,
|
|
||||||
)
|
|
||||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
data, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals")
|
|
||||||
}
|
|
||||||
if val, ok := values[0].(uint8); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
return 0, merrors.Internal("decimals returned unexpected type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
|
|
||||||
data, err := tokenABI.Pack("balanceOf", wallet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("balanceOf", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return nil, merrors.Internal("failed to unpack balanceOf")
|
|
||||||
}
|
|
||||||
raw, ok := values[0].(*big.Int)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, merrors.Internal("balanceOf returned unexpected type")
|
logger.Warn("Requested network is not configured",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", networkKey),
|
||||||
|
)
|
||||||
|
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||||
}
|
}
|
||||||
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20ABIJSON = `
|
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||||
[
|
if err != nil {
|
||||||
{
|
logger.Warn("Chain driver not configured",
|
||||||
"constant": true,
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
"inputs": [],
|
zap.String("network", networkKey),
|
||||||
"name": "decimals",
|
zap.Error(err),
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
)
|
||||||
"payable": false,
|
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [{ "name": "_owner", "type": "address" }],
|
|
||||||
"name": "balanceOf",
|
|
||||||
"outputs": [{ "name": "balance", "type": "uint256" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
}
|
}
|
||||||
]`
|
|
||||||
|
driverDeps := driver.Deps{
|
||||||
|
Logger: deps.Logger,
|
||||||
|
Registry: deps.Networks,
|
||||||
|
KeyManager: deps.KeyManager,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
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()),
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package arbitrum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Arbitrum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("arbitrum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "arbitrum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("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) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("estimate fee 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) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package driver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps bundles dependencies shared across chain drivers.
|
||||||
|
type Deps struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
Registry *rpcclient.Registry
|
||||||
|
KeyManager keymanager.Manager
|
||||||
|
RPCTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver defines chain-specific behavior for wallet and transfer operations.
|
||||||
|
type Driver interface {
|
||||||
|
Name() string
|
||||||
|
FormatAddress(address string) (string, error)
|
||||||
|
NormalizeAddress(address string) (string, error)
|
||||||
|
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
|
||||||
|
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
|
||||||
|
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Ethereum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("ethereum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "ethereum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("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) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("estimate fee 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) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
680
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
680
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
erc20ABI abi.ABI
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||||
|
if err != nil {
|
||||||
|
panic("evm driver: failed to parse erc20 abi: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const erc20ABIJSON = `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"constant": false,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "_to", "type": "address" },
|
||||||
|
{ "name": "_value", "type": "uint256" }
|
||||||
|
],
|
||||||
|
"name": "transfer",
|
||||||
|
"outputs": [{ "name": "", "type": "bool" }],
|
||||||
|
"payable": false,
|
||||||
|
"stateMutability": "nonpayable",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"constant": true,
|
||||||
|
"inputs": [],
|
||||||
|
"name": "decimals",
|
||||||
|
"outputs": [{ "name": "", "type": "uint8" }],
|
||||||
|
"payable": false,
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
// NormalizeAddress validates and normalizes EVM hex addresses.
|
||||||
|
func NormalizeAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(trimmed) {
|
||||||
|
return "", merrors.InvalidArgument("invalid hex address")
|
||||||
|
}
|
||||||
|
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeCurrency(network shared.Network) string {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.ToUpper(network.Name)
|
||||||
|
}
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
||||||
|
trimmed := strings.TrimSpace(amount)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
value, ok := new(big.Int).SetString(trimmed, 10)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("invalid amount")
|
||||||
|
}
|
||||||
|
if value.Sign() < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("amount must be non-negative")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance fetches ERC20 token balance for the provided address.
|
||||||
|
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger
|
||||||
|
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("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
|
||||||
|
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||||
|
if contract == "" {
|
||||||
|
logger.Debug("Native balance requested", logFields...)
|
||||||
|
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
logger.Warn("Invalid contract address for balance fetch", logFields...)
|
||||||
|
return nil, merrors.InvalidArgument("invalid contract address")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Fetching on-chain wallet balance", logFields...)
|
||||||
|
|
||||||
|
rpcClient, err := registry.RPCClient(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()
|
||||||
|
|
||||||
|
logger.Debug("Calling token decimals", logFields...)
|
||||||
|
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
|
||||||
|
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
||||||
|
logger.Info("On-chain wallet balance fetched",
|
||||||
|
append(logFields,
|
||||||
|
zap.Uint8("decimals", decimals),
|
||||||
|
zap.String("balance_raw", bal.String()),
|
||||||
|
zap.String("balance", dec.String()),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeBalance fetches native token balance for the provided address.
|
||||||
|
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedAddress, err := NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
logFields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||||
|
zap.String("wallet_address", normalizedAddress),
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url is not configured", logFields...)
|
||||||
|
return nil, merrors.Internal("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := deps.RPCTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("On-chain native balance fetched",
|
||||||
|
append(logFields,
|
||||||
|
zap.String("balance_raw", bal.String()),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: bal.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateFee estimates ERC20 transfer fees for the given parameters.
|
||||||
|
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if amount == nil {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
return nil, merrors.InvalidArgument("network rpc url not configured")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid source wallet address")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(destination); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid destination address")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := deps.RPCTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||||
|
toAddr := common.HexToAddress(destination)
|
||||||
|
fromAddr := common.HexToAddress(fromAddress)
|
||||||
|
|
||||||
|
if contract == "" {
|
||||||
|
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||||
|
}
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: fromAddr,
|
||||||
|
To: &toAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Value: amountBase,
|
||||||
|
}
|
||||||
|
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||||
|
}
|
||||||
|
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||||
|
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: feeDec.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenAddr := common.HexToAddress(contract)
|
||||||
|
|
||||||
|
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to read token decimals", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
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: &tokenAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Data: input,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
|
||||||
|
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
|
||||||
|
logger := deps.Logger
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if deps.KeyManager == nil {
|
||||||
|
logger.Warn("Key manager not configured")
|
||||||
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
|
}
|
||||||
|
if registry == nil {
|
||||||
|
return "", executorInternal("rpc clients not initialised", nil)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||||
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
if source == nil || transfer == nil {
|
||||||
|
logger.Warn("Transfer context missing")
|
||||||
|
return "", executorInvalid("transfer context missing")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
|
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||||
|
logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("invalid source wallet address")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(destination); err != nil {
|
||||||
|
logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination))
|
||||||
|
return "", executorInvalid("invalid destination address " + destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("submitting transfer",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("source_wallet_ref", source.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", strings.ToLower(destination)),
|
||||||
|
)
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("failed to initialise rpc client", zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceAddress := common.HexToAddress(fromAddress)
|
||||||
|
destinationAddr := common.HexToAddress(destination)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to suggest gas price", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to suggest gas price", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(transfer.ContractAddress)
|
||||||
|
amount := transfer.NetAmount
|
||||||
|
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||||
|
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", executorInvalid("transfer missing net amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx *types.Transaction
|
||||||
|
if contract == "" {
|
||||||
|
amountInt, err := parseBaseUnitAmount(amount.Amount)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: sourceAddress,
|
||||||
|
To: &destinationAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Value: amountInt,
|
||||||
|
}
|
||||||
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to estimate gas", err)
|
||||||
|
}
|
||||||
|
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
|
||||||
|
} else {
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
logger.Warn("Invalid token contract address",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", contract),
|
||||||
|
)
|
||||||
|
return "", executorInvalid("invalid token contract address " + contract)
|
||||||
|
}
|
||||||
|
tokenAddress := common.HexToAddress(contract)
|
||||||
|
|
||||||
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", contract),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("amount", amount.Amount),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to encode transfer call", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: sourceAddress,
|
||||||
|
To: &tokenAddress,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Data: input,
|
||||||
|
}
|
||||||
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to estimate gas", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
|
logger.Warn("Failed to send transaction", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to send transaction", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash := signedTx.Hash().Hex()
|
||||||
|
logger.Info("Transaction submitted",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
return txHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwaitConfirmation waits for the transaction receipt.
|
||||||
|
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
logger := deps.Logger
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if strings.TrimSpace(txHash) == "" {
|
||||||
|
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
|
return nil, executorInvalid("tx hash is required")
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||||
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
if registry == nil {
|
||||||
|
return nil, executorInternal("rpc clients not initialised", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := common.HexToHash(txHash)
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
receipt, err := client.TransactionReceipt(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ethereum.NotFound) {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
logger.Debug("Transaction not yet mined",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Warn("Context cancelled while awaiting confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Warn("Failed to fetch transaction receipt",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||||
|
}
|
||||||
|
logger.Info("Transaction confirmed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(common.HexToAddress(token).Hex()),
|
||||||
|
"data": "0x313ce567",
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
||||||
|
}
|
||||||
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.Internal("decimals decode failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
|
||||||
|
tokenAddr := common.HexToAddress(token)
|
||||||
|
walletAddr := common.HexToAddress(wallet)
|
||||||
|
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
|
||||||
|
if len(addr) < 64 {
|
||||||
|
addr = strings.Repeat("0", 64-len(addr)) + addr
|
||||||
|
}
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(tokenAddr.Hex()),
|
||||||
|
"data": "0x70a08231" + addr,
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
||||||
|
}
|
||||||
|
bigVal, err := shared.DecodeHexBig(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return bigVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(token.Hex()),
|
||||||
|
"data": "0x313ce567",
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return 0, executorInternal("decimals call failed", err)
|
||||||
|
}
|
||||||
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
||||||
|
}
|
||||||
|
if value.IsNegative() {
|
||||||
|
return nil, merrors.InvalidArgument("amount must be positive")
|
||||||
|
}
|
||||||
|
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||||
|
scaled := value.Mul(multiplier)
|
||||||
|
if !scaled.Equal(scaled.Truncate(0)) {
|
||||||
|
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
||||||
|
}
|
||||||
|
return scaled.BigInt(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executorInvalid(msg string) error {
|
||||||
|
return merrors.InvalidArgument("executor: " + msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executorInternal(msg string, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
msg = msg + ": " + err.Error()
|
||||||
|
}
|
||||||
|
return merrors.Internal("executor: " + msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
|
||||||
|
|
||||||
|
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
|
||||||
|
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, false, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, false, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, false, merrors.InvalidArgument("current native balance is required")
|
||||||
|
}
|
||||||
|
if network.GasTopUpPolicy == nil {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||||
|
if nativeCurrency == "" {
|
||||||
|
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||||
|
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||||
|
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedNative, err := evmToNative(estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
currentNative, err := evmToNative(currentBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||||
|
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||||
|
}
|
||||||
|
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := estimatedNative.Sub(currentNative)
|
||||||
|
if required.IsNegative() {
|
||||||
|
required = decimal.Zero
|
||||||
|
}
|
||||||
|
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||||
|
|
||||||
|
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
|
||||||
|
if minBalanceTopUp.IsNegative() {
|
||||||
|
minBalanceTopUp = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTopUp := bufferedRequired
|
||||||
|
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||||
|
rawTopUp = minBalanceTopUp
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedTopUp := decimal.Zero
|
||||||
|
if rawTopUp.IsPositive() {
|
||||||
|
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := roundedTopUp
|
||||||
|
capHit := false
|
||||||
|
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||||
|
topUp = rule.MaxTopUp
|
||||||
|
capHit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topUp.IsPositive() {
|
||||||
|
return nil, capHit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(nativeCurrency),
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}, capHit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return value.Div(evmBaseUnitFactor), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "46000000000000000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "ETH", topUp.GetCurrency())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "19000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "2000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.True(t, capHit)
|
||||||
|
require.Equal(t, "10000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.1),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(10),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
Contract: &shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.5),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(5),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||||
|
return &shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.15),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(20),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(500),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||||
|
return shared.Network{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
|
GasTopUpPolicy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ethMoney(eth string) *moneyv1.Money {
|
||||||
|
value, _ := decimal.NewFromString(eth)
|
||||||
|
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: "ETH",
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tronHexPrefix = "0x"
|
||||||
|
|
||||||
|
var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
|
||||||
|
|
||||||
|
func normalizeAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||||
|
return hexToBase58(trimmed)
|
||||||
|
}
|
||||||
|
decoded, err := base58Decode(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := validateChecksum(decoded); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base58Encode(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||||
|
return normalizeHexRPC(trimmed)
|
||||||
|
}
|
||||||
|
return base58ToHex(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToBase58(address string) (string, error) {
|
||||||
|
bytesAddr, err := parseHexAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload := append(bytesAddr, checksum(bytesAddr)...)
|
||||||
|
return base58Encode(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58ToHex(address string) (string, error) {
|
||||||
|
decoded, err := base58Decode(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := validateChecksum(decoded); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexAddress(address string) ([]byte, error) {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if len(trimmed)%2 == 1 {
|
||||||
|
trimmed = "0" + trimmed
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid hex address")
|
||||||
|
}
|
||||||
|
switch len(decoded) {
|
||||||
|
case 20:
|
||||||
|
prefixed := make([]byte, 21)
|
||||||
|
prefixed[0] = 0x41
|
||||||
|
copy(prefixed[1:], decoded)
|
||||||
|
return prefixed, nil
|
||||||
|
case 21:
|
||||||
|
if decoded[0] != 0x41 {
|
||||||
|
return nil, merrors.InvalidArgument("invalid tron address prefix")
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHexRPC(address string) (string, error) {
|
||||||
|
decoded, err := parseHexAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChecksum(decoded []byte) error {
|
||||||
|
if len(decoded) != 25 {
|
||||||
|
return merrors.InvalidArgument("invalid tron address length")
|
||||||
|
}
|
||||||
|
payload := decoded[:21]
|
||||||
|
expected := checksum(payload)
|
||||||
|
if !bytes.Equal(expected, decoded[21:]) {
|
||||||
|
return merrors.InvalidArgument("invalid tron address checksum")
|
||||||
|
}
|
||||||
|
if payload[0] != 0x41 {
|
||||||
|
return merrors.InvalidArgument("invalid tron address prefix")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checksum(payload []byte) []byte {
|
||||||
|
first := sha256.Sum256(payload)
|
||||||
|
second := sha256.Sum256(first[:])
|
||||||
|
return second[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58Encode(input []byte) string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
x := new(big.Int).SetBytes(input)
|
||||||
|
base := big.NewInt(58)
|
||||||
|
zero := big.NewInt(0)
|
||||||
|
mod := new(big.Int)
|
||||||
|
|
||||||
|
encoded := make([]byte, 0, len(input))
|
||||||
|
for x.Cmp(zero) > 0 {
|
||||||
|
x.DivMod(x, base, mod)
|
||||||
|
encoded = append(encoded, base58Alphabet[mod.Int64()])
|
||||||
|
}
|
||||||
|
for _, b := range input {
|
||||||
|
if b != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
encoded = append(encoded, base58Alphabet[0])
|
||||||
|
}
|
||||||
|
reverse(encoded)
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58Decode(input string) ([]byte, error) {
|
||||||
|
result := big.NewInt(0)
|
||||||
|
base := big.NewInt(58)
|
||||||
|
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
idx := bytes.IndexByte(base58Alphabet, input[i])
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("invalid base58 address")
|
||||||
|
}
|
||||||
|
result.Mul(result, base)
|
||||||
|
result.Add(result, big.NewInt(int64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := result.Bytes()
|
||||||
|
zeroCount := 0
|
||||||
|
for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] {
|
||||||
|
zeroCount++
|
||||||
|
}
|
||||||
|
if zeroCount > 0 {
|
||||||
|
decoded = append(make([]byte, zeroCount), decoded...)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverse(data []byte) {
|
||||||
|
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
data[i], data[j] = data[j], data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexString(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
223
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
223
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Tron-specific behavior, including address conversion.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("tron")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "tron"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Format address", zap.String("address", address))
|
||||||
|
normalized, err := normalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||||
|
normalized, err := normalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(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("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||||
|
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("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.Balance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Balance failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("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) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||||
|
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("address", wallet.DepositAddress),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
rpcFrom, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("address", wallet.DepositAddress),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpcTo, err := rpcAddress(destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Estimate fee 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) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
if source == nil {
|
||||||
|
return "", merrors.InvalidArgument("source wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
rpcFrom, err := rpcAddress(source.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
zap.String("address", source.DepositAddress),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcTo, err := rpcAddress(destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("submit transfer failed", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("Submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("Awaiting confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Awaiting of confirmation failed", zap.Error(err),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("Await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
|
||||||
|
|
||||||
|
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
|
||||||
|
type GasTopUpDecision struct {
|
||||||
|
CurrentBalanceTRX decimal.Decimal
|
||||||
|
EstimatedFeeTRX decimal.Decimal
|
||||||
|
RequiredTRX decimal.Decimal
|
||||||
|
BufferedRequiredTRX decimal.Decimal
|
||||||
|
MinBalanceTopUpTRX decimal.Decimal
|
||||||
|
RawTopUpTRX decimal.Decimal
|
||||||
|
RoundedTopUpTRX decimal.Decimal
|
||||||
|
TopUpTRX decimal.Decimal
|
||||||
|
CapHit bool
|
||||||
|
OperationType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
|
||||||
|
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
|
||||||
|
decision := GasTopUpDecision{}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("current native balance is required")
|
||||||
|
}
|
||||||
|
if network.GasTopUpPolicy == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||||
|
if nativeCurrency == "" {
|
||||||
|
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedTRX, err := tronToTRX(estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
currentTRX, err := tronToTRX(currentBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||||
|
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||||
|
if !ok {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||||
|
}
|
||||||
|
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := estimatedTRX.Sub(currentTRX)
|
||||||
|
if required.IsNegative() {
|
||||||
|
required = decimal.Zero
|
||||||
|
}
|
||||||
|
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||||
|
|
||||||
|
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
|
||||||
|
if minBalanceTopUp.IsNegative() {
|
||||||
|
minBalanceTopUp = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTopUp := bufferedRequired
|
||||||
|
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||||
|
rawTopUp = minBalanceTopUp
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedTopUp := decimal.Zero
|
||||||
|
if rawTopUp.IsPositive() {
|
||||||
|
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := roundedTopUp
|
||||||
|
capHit := false
|
||||||
|
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||||
|
topUp = rule.MaxTopUp
|
||||||
|
capHit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = GasTopUpDecision{
|
||||||
|
CurrentBalanceTRX: currentTRX,
|
||||||
|
EstimatedFeeTRX: estimatedTRX,
|
||||||
|
RequiredTRX: required,
|
||||||
|
BufferedRequiredTRX: bufferedRequired,
|
||||||
|
MinBalanceTopUpTRX: minBalanceTopUp,
|
||||||
|
RawTopUpTRX: rawTopUp,
|
||||||
|
RoundedTopUpTRX: roundedTopUp,
|
||||||
|
TopUpTRX: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
OperationType: operationType(isContract),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topUp.IsPositive() {
|
||||||
|
return nil, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(nativeCurrency),
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return value.Div(tronBaseUnitFactor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationType(contract bool) string {
|
||||||
|
if contract {
|
||||||
|
return "trc20"
|
||||||
|
}
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, topUp)
|
||||||
|
require.True(t, decision.TopUpTRX.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "46000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "TRX", topUp.GetCurrency())
|
||||||
|
require.Equal(t, "46", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "19000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "19", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "2000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "2", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "10000000", topUp.GetAmount())
|
||||||
|
require.True(t, decision.CapHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.1),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(10),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
Contract: &shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.5),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(5),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
require.Equal(t, "trc20", decision.OperationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||||
|
return &shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.15),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(20),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(500),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||||
|
return shared.Network{
|
||||||
|
Name: "tron_mainnet",
|
||||||
|
NativeToken: "TRX",
|
||||||
|
GasTopUpPolicy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronMoney(trx string) *moneyv1.Money {
|
||||||
|
value, _ := decimal.NewFromString(trx)
|
||||||
|
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: "TRX",
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry maps configured network keys to chain drivers.
|
||||||
|
type Registry struct {
|
||||||
|
byNetwork map[string]driver.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry selects drivers for the configured networks.
|
||||||
|
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: logger is required")
|
||||||
|
}
|
||||||
|
result := &Registry{byNetwork: map[string]driver.Driver{}}
|
||||||
|
for _, network := range networks {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chainDriver, err := resolveDriver(logger, name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.byNetwork[name] = chainDriver
|
||||||
|
}
|
||||||
|
if len(result.byNetwork) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
|
||||||
|
}
|
||||||
|
logger.Info("chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver resolves a driver for the provided network key.
|
||||||
|
func (r *Registry) Driver(network string) (driver.Driver, error) {
|
||||||
|
if r == nil || len(r.byNetwork) == 0 {
|
||||||
|
return nil, merrors.Internal("driver registry is not configured")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if key == "" {
|
||||||
|
return nil, merrors.InvalidArgument("network is required")
|
||||||
|
}
|
||||||
|
chainDriver, ok := r.byNetwork[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
|
||||||
|
}
|
||||||
|
return chainDriver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(network, "tron"):
|
||||||
|
return tron.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "arbitrum"):
|
||||||
|
return arbitrum.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "ethereum"):
|
||||||
|
return ethereum.New(logger), nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument("unsupported chain network " + network)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ type TransferExecutor interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
||||||
return &onChainExecutor{
|
return &onChainExecutor{
|
||||||
logger: logger.Named("executor"),
|
logger: logger.Named("executor"),
|
||||||
keyManager: keyManager,
|
keyManager: keyManager,
|
||||||
clients: map[string]*ethclient.Client{},
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +42,7 @@ type onChainExecutor struct {
|
|||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
|
|
||||||
mu sync.Mutex
|
clients *rpcclient.Clients
|
||||||
clients map[string]*ethclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||||
@@ -80,11 +79,15 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||||
)
|
)
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to initialise rpc client",
|
o.logger.Warn("failed to initialise rpc client",
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("rpc_url", rpcURL),
|
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
@@ -98,10 +101,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to fetch nonce",
|
o.logger.Warn("failed to fetch nonce", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to fetch nonce", err)
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
}
|
}
|
||||||
@@ -135,12 +137,11 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to read token decimals",
|
o.logger.Warn("failed to read token decimals", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -152,10 +153,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to convert amount to base units",
|
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("amount", amount.Amount),
|
zap.String("amount", amount.Amount),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -188,18 +188,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to sign transaction",
|
o.logger.Warn("failed to sign transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
o.logger.Warn("failed to send transaction",
|
o.logger.Warn("failed to send transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to send transaction", err)
|
return "", executorInternal("failed to send transaction", err)
|
||||||
}
|
}
|
||||||
@@ -214,30 +212,6 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
return txHash, nil
|
return txHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
|
||||||
o.mu.Lock()
|
|
||||||
client, ok := o.clients[rpcURL]
|
|
||||||
o.mu.Unlock()
|
|
||||||
if ok {
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
o.mu.Lock()
|
|
||||||
defer o.mu.Unlock()
|
|
||||||
if existing, ok := o.clients[rpcURL]; ok {
|
|
||||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
|
||||||
c.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
o.clients[rpcURL] = c
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
if strings.TrimSpace(txHash) == "" {
|
if strings.TrimSpace(txHash) == "" {
|
||||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
@@ -249,7 +223,7 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
|||||||
return nil, executorInvalid("network rpc url is not configured")
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
|
|||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
callData, err := erc20ABI.Pack("decimals")
|
call := map[string]string{
|
||||||
if err != nil {
|
"to": strings.ToLower(token.Hex()),
|
||||||
return 0, executorInternal("failed to encode decimals call", err)
|
"data": "0x313ce567",
|
||||||
}
|
}
|
||||||
msg := ethereum.CallMsg{
|
var hexResp string
|
||||||
To: &token,
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, executorInternal("decimals call failed", err)
|
return 0, executorInternal("decimals call failed", err)
|
||||||
}
|
}
|
||||||
values, err := erc20ABI.Unpack("decimals", output)
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, executorInternal("failed to unpack decimals", err)
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
}
|
}
|
||||||
if len(values) == 0 {
|
return val, nil
|
||||||
return 0, executorInternal("decimals call returned no data", nil)
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, executorInternal("decimals call returned unexpected type", nil)
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
// WithRPCClients configures pre-initialised RPC clients.
|
||||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
func WithRPCClients(clients *rpcclient.Clients) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.executor = executor
|
s.rpcClients = clients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDriverRegistry configures the chain driver registry.
|
||||||
|
func WithDriverRegistry(registry *drivers.Registry) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.drivers = registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithClock overrides the service clock.
|
// WithClock overrides the service clock.
|
||||||
func WithClock(clk clockpkg.Clock) Option {
|
func WithClock(clk clockpkg.Clock) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import (
|
|||||||
// Clients holds pre-initialised RPC clients keyed by network name.
|
// Clients holds pre-initialised RPC clients keyed by network name.
|
||||||
type Clients struct {
|
type Clients struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
clients map[string]*ethclient.Client
|
clients map[string]clientEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientEntry struct {
|
||||||
|
eth *ethclient.Client
|
||||||
|
rpc *rpc.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare dials all configured networks up front and returns a ready-to-use client set.
|
// Prepare dials all configured networks up front and returns a ready-to-use client set.
|
||||||
@@ -30,14 +35,14 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
|||||||
clientLogger := logger.Named("rpc_client")
|
clientLogger := logger.Named("rpc_client")
|
||||||
result := &Clients{
|
result := &Clients{
|
||||||
logger: clientLogger,
|
logger: clientLogger,
|
||||||
clients: make(map[string]*ethclient.Client),
|
clients: make(map[string]clientEntry),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, network := range networks {
|
for _, network := range networks {
|
||||||
name := strings.ToLower(strings.TrimSpace(network.Name))
|
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
clientLogger.Warn("skipping network with empty name during rpc client preparation")
|
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
@@ -55,9 +60,10 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
|||||||
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: &loggingRoundTripper{
|
Transport: &loggingRoundTripper{
|
||||||
logger: clientLogger,
|
logger: clientLogger,
|
||||||
network: name,
|
network: name,
|
||||||
base: http.DefaultTransport,
|
endpoint: rpcURL,
|
||||||
|
base: http.DefaultTransport,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
|
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
|
||||||
@@ -68,13 +74,18 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
|||||||
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||||
}
|
}
|
||||||
client := ethclient.NewClient(rpcCli)
|
client := ethclient.NewClient(rpcCli)
|
||||||
|
result.clients[name] = clientEntry{
|
||||||
result.clients[name] = client
|
eth: client,
|
||||||
|
rpc: rpcCli,
|
||||||
|
}
|
||||||
clientLogger.Info("rpc client ready", fields...)
|
clientLogger.Info("rpc client ready", fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.clients) == 0 {
|
if len(result.clients) == 0 {
|
||||||
|
clientLogger.Warn("No rpc clients were initialised")
|
||||||
return nil, merrors.InvalidArgument("no rpc clients initialised")
|
return nil, merrors.InvalidArgument("no rpc clients initialised")
|
||||||
|
} else {
|
||||||
|
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -86,11 +97,24 @@ func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
|||||||
return nil, merrors.Internal("rpc clients not initialised")
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
}
|
}
|
||||||
name := strings.ToLower(strings.TrimSpace(network))
|
name := strings.ToLower(strings.TrimSpace(network))
|
||||||
client, ok := c.clients[name]
|
entry, ok := c.clients[name]
|
||||||
if !ok {
|
if !ok || entry.eth == nil {
|
||||||
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||||
}
|
}
|
||||||
return client, nil
|
return entry.eth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCClient returns the raw RPC client for low-level calls.
|
||||||
|
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
entry, ok := c.clients[name]
|
||||||
|
if !ok || entry.rpc == nil {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||||
|
}
|
||||||
|
return entry.rpc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close tears down all RPC clients, logging each close.
|
// Close tears down all RPC clients, logging each close.
|
||||||
@@ -98,8 +122,12 @@ func (c *Clients) Close() {
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for name, client := range c.clients {
|
for name, entry := range c.clients {
|
||||||
client.Close()
|
if entry.rpc != nil {
|
||||||
|
entry.rpc.Close()
|
||||||
|
} else if entry.eth != nil {
|
||||||
|
entry.eth.Close()
|
||||||
|
}
|
||||||
if c.logger != nil {
|
if c.logger != nil {
|
||||||
c.logger.Info("rpc client closed", zap.String("network", name))
|
c.logger.Info("rpc client closed", zap.String("network", name))
|
||||||
}
|
}
|
||||||
@@ -151,9 +179,7 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
l.logger.Warn("rpc response error", respFields...)
|
l.logger.Warn("RPC response error", respFields...)
|
||||||
} else {
|
|
||||||
l.logger.Debug("rpc response", respFields...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package rpcclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry binds static network metadata with prepared RPC clients.
|
||||||
|
type Registry struct {
|
||||||
|
networks map[string]shared.Network
|
||||||
|
clients *Clients
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry constructs a registry keyed by lower-cased network name.
|
||||||
|
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
|
||||||
|
return &Registry{
|
||||||
|
networks: networks,
|
||||||
|
clients: clients,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network fetches network metadata by key (case-insensitive).
|
||||||
|
func (r *Registry) Network(key string) (shared.Network, bool) {
|
||||||
|
if r == nil || len(r.networks) == 0 {
|
||||||
|
return shared.Network{}, false
|
||||||
|
}
|
||||||
|
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns the prepared RPC client for the given network name.
|
||||||
|
func (r *Registry) Client(key string) (*ethclient.Client, error) {
|
||||||
|
if r == nil || r.clients == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCClient returns the raw RPC client for low-level calls.
|
||||||
|
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
|
||||||
|
if r == nil || r.clients == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networks exposes the registry map for iteration when needed.
|
||||||
|
func (r *Registry) Networks() map[string]shared.Network {
|
||||||
|
return r.networks
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
@@ -38,11 +40,13 @@ type Service struct {
|
|||||||
|
|
||||||
settings CacheSettings
|
settings CacheSettings
|
||||||
|
|
||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
executor TransferExecutor
|
rpcClients *rpcclient.Clients
|
||||||
commands commands.Registry
|
networkRegistry *rpcclient.Registry
|
||||||
|
drivers *drivers.Registry
|
||||||
|
commands commands.Registry
|
||||||
|
|
||||||
chainv1.UnimplementedChainGatewayServiceServer
|
chainv1.UnimplementedChainGatewayServiceServer
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
svc.settings = svc.settings.withDefaults()
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
@@ -121,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
|
|||||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||||
if s.storage == nil {
|
if s.storage == nil {
|
||||||
return errStorageUnavailable
|
return errStorageUnavailable
|
||||||
@@ -131,11 +144,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
|||||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||||
return wallet.Deps{
|
return wallet.Deps{
|
||||||
Logger: s.logger.Named("command"),
|
Logger: s.logger.Named("command"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +158,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||||
return transfer.Deps{
|
return transfer.Deps{
|
||||||
Logger: s.logger.Named("transfer_cmd"),
|
Logger: s.logger.Named("transfer_cmd"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
LaunchExecution: s.launchTransferExecution,
|
LaunchExecution: s.launchTransferExecution,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
@@ -65,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()
|
||||||
@@ -143,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 {
|
||||||
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
|
|||||||
return int64(requested)
|
return int64(requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||||
repo := newInMemoryRepository()
|
repo := newInMemoryRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
networks := []shared.Network{{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
|
TokenConfigs: []shared.TokenContract{
|
||||||
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
|
||||||
|
require.NoError(t, err)
|
||||||
svc := NewService(logger, repo, nil,
|
svc := NewService(logger, repo, nil,
|
||||||
WithKeyManager(&fakeKeyManager{}),
|
WithKeyManager(&fakeKeyManager{}),
|
||||||
WithNetworks([]shared.Network{{
|
WithNetworks(networks),
|
||||||
Name: "ethereum_mainnet",
|
|
||||||
TokenConfigs: []shared.TokenContract{
|
|
||||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
|
||||||
},
|
|
||||||
}}),
|
|
||||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||||
|
WithDriverRegistry(driverRegistry),
|
||||||
)
|
)
|
||||||
return svc, repo
|
return svc, repo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package gateway
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
const defaultRPCRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
// CacheSettings holds tunable gateway behaviour.
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
type CacheSettings struct {
|
type CacheSettings struct {
|
||||||
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultSettings() CacheSettings {
|
func defaultSettings() CacheSettings {
|
||||||
return CacheSettings{
|
return CacheSettings{
|
||||||
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
|
|||||||
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
}
|
}
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) rpcTimeout() time.Duration {
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
return defaultRPCRequestTimeout
|
||||||
|
}
|
||||||
|
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "github.com/shopspring/decimal"
|
||||||
|
|
||||||
|
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
|
||||||
|
type GasTopUpRule struct {
|
||||||
|
BufferPercent decimal.Decimal
|
||||||
|
MinNativeBalance decimal.Decimal
|
||||||
|
RoundingUnit decimal.Decimal
|
||||||
|
MaxTopUp decimal.Decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
|
||||||
|
type GasTopUpPolicy struct {
|
||||||
|
Default GasTopUpRule
|
||||||
|
Native *GasTopUpRule
|
||||||
|
Contract *GasTopUpRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule selects the policy rule for the transfer type.
|
||||||
|
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
|
||||||
|
if p == nil {
|
||||||
|
return GasTopUpRule{}, false
|
||||||
|
}
|
||||||
|
if contractTransfer && p.Contract != nil {
|
||||||
|
return *p.Contract, true
|
||||||
|
}
|
||||||
|
if !contractTransfer && p.Native != nil {
|
||||||
|
return *p.Native, true
|
||||||
|
}
|
||||||
|
return p.Default, true
|
||||||
|
}
|
||||||
@@ -121,11 +121,12 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
|
|||||||
|
|
||||||
// Network describes a supported blockchain network and known token contracts.
|
// Network describes a supported blockchain network and known token contracts.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
Name string
|
Name string
|
||||||
RPCURL string
|
RPCURL string
|
||||||
ChainID uint64
|
ChainID uint64
|
||||||
NativeToken string
|
NativeToken string
|
||||||
TokenConfigs []TokenContract
|
TokenConfigs []TokenContract
|
||||||
|
GasTopUpPolicy *GasTopUpPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||||
|
|||||||
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHexEmpty = errors.New("hex value is empty")
|
||||||
|
errHexInvalid = errors.New("invalid hex number")
|
||||||
|
errHexOutOfRange = errors.New("hex number out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||||
|
func DecodeHexBig(input string) (*big.Int, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
noPrefix := strings.TrimPrefix(trimmed, "0x")
|
||||||
|
if noPrefix == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
value := strings.TrimLeft(noPrefix, "0")
|
||||||
|
if value == "" {
|
||||||
|
return big.NewInt(0), nil
|
||||||
|
}
|
||||||
|
val := new(big.Int)
|
||||||
|
if _, ok := val.SetString(value, 16); !ok {
|
||||||
|
return nil, errHexInvalid
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
|
||||||
|
func DecodeHexUint8(input string) (uint8, error) {
|
||||||
|
val, err := DecodeHexBig(input)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return 0, errHexInvalid
|
||||||
|
}
|
||||||
|
if val.BitLen() > 8 {
|
||||||
|
return 0, errHexOutOfRange
|
||||||
|
}
|
||||||
|
return uint8(val.Uint64()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
|
||||||
|
val, err := DecodeHexUint8(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeHexUint8 error: %v", err)
|
||||||
|
}
|
||||||
|
if val != 6 {
|
||||||
|
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||||
if s.executor == nil {
|
if s.drivers == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
driverDeps := s.driverDeps()
|
||||||
|
chainDriver, err := s.driverForNetwork(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
@@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
|
|
||||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
@@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverDeps() driver.Deps {
|
||||||
|
return driver.Deps{
|
||||||
|
Logger: s.logger.Named("driver"),
|
||||||
|
Registry: s.networkRegistry,
|
||||||
|
KeyManager: s.keyManager,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
|
||||||
|
if s.drivers == nil {
|
||||||
|
return nil, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
return s.drivers.Driver(network)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -91,7 +92,7 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
|
||||||
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
func (b *WalletBalance) Normalize() {
|
func (b *WalletBalance) Normalize() {
|
||||||
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddress(address string) string {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isHexAddress(trimmed) {
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexAddress(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||||
|
if len(trimmed) != 40 && len(trimmed) != 42 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ oracle:
|
|||||||
|
|
||||||
card_gateways:
|
card_gateways:
|
||||||
monetix:
|
monetix:
|
||||||
funding_address: "wallet_funding_monetix"
|
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||||
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,214 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var estimatedTotalFee *moneyv1.Money
|
||||||
|
if gasCurrency != "" && !totalFee.IsNegative() {
|
||||||
|
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
var topUpMoney *moneyv1.Money
|
||||||
|
var topUpFee *moneyv1.Money
|
||||||
|
topUpPositive := false
|
||||||
|
if estimatedTotalFee != nil {
|
||||||
|
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
|
||||||
|
WalletRef: sourceWalletRef,
|
||||||
|
EstimatedTotalFee: estimatedTotalFee,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if computeResp != nil {
|
||||||
|
topUpMoney = computeResp.GetTopupAmount()
|
||||||
|
}
|
||||||
|
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
|
||||||
|
amountDec, err := decimalFromMoney(topUpMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
topUpPositive = amountDec.IsPositive()
|
||||||
|
}
|
||||||
|
if topUpMoney != nil && topUpPositive {
|
||||||
|
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||||
|
}
|
||||||
|
if feeWalletRef == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
|
||||||
|
}
|
||||||
|
topUpDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||||
|
}
|
||||||
|
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := ensureExecutionPlan(payment)
|
||||||
|
var gasStep *model.ExecutionStep
|
||||||
|
if topUpMoney != nil && topUpPositive {
|
||||||
|
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 && topUpPositive {
|
||||||
|
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
|
||||||
|
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
|
||||||
|
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||||
|
SourceWalletRef: feeWalletRef,
|
||||||
|
TargetWalletRef: sourceWalletRef,
|
||||||
|
EstimatedTotalFee: estimatedTotalFee,
|
||||||
|
Metadata: cloneMetadata(payment.Metadata),
|
||||||
|
ClientReference: payment.PaymentRef,
|
||||||
|
})
|
||||||
|
if gasErr != nil {
|
||||||
|
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return gasErr
|
||||||
|
}
|
||||||
|
if gasStep != nil {
|
||||||
|
actual := (*moneyv1.Money)(nil)
|
||||||
|
if ensureResp != nil {
|
||||||
|
actual = ensureResp.GetTopupAmount()
|
||||||
|
if transfer := ensureResp.GetTransfer(); transfer != nil {
|
||||||
|
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actualPositive := false
|
||||||
|
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
|
||||||
|
actualDec, err := decimalFromMoney(actual)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actualPositive = actualDec.IsPositive()
|
||||||
|
}
|
||||||
|
if actual != nil && actualPositive {
|
||||||
|
gasStep.Amount = cloneMoney(actual)
|
||||||
|
if strings.TrimSpace(actual.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||||
|
}
|
||||||
|
topUpDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||||
|
}
|
||||||
|
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gasStep.NetworkFee = cloneMoney(topUpFee)
|
||||||
|
} else {
|
||||||
|
gasStep.Amount = nil
|
||||||
|
gasStep.NetworkFee = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gasStep != nil {
|
||||||
|
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
|
||||||
|
}
|
||||||
|
updateExecutionPlanTotalNetworkFee(plan)
|
||||||
|
}
|
||||||
|
|
||||||
// Transfer payout amount to funding wallet.
|
// 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 +282,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 +299,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 +359,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 +495,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,407 @@
|
|||||||
|
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 computeCalls []*chainv1.ComputeGasTopUpRequest
|
||||||
|
var ensureCalls []*chainv1.EnsureGasTopUpRequest
|
||||||
|
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
|
||||||
|
},
|
||||||
|
ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
computeCalls = append(computeCalls, req)
|
||||||
|
return &chainv1.ComputeGasTopUpResponse{
|
||||||
|
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
ensureCalls = append(ensureCalls, req)
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||||
|
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
|
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) != 4 {
|
||||||
|
t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls))
|
||||||
|
}
|
||||||
|
if len(computeCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls))
|
||||||
|
}
|
||||||
|
if len(ensureCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
|
||||||
|
}
|
||||||
|
if len(submitCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
computeCall := computeCalls[0]
|
||||||
|
if computeCall.GetWalletRef() != sourceWalletRef {
|
||||||
|
t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef())
|
||||||
|
}
|
||||||
|
if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||||
|
t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCall := ensureCalls[0]
|
||||||
|
if ensureCall.GetSourceWalletRef() != feeWalletRef {
|
||||||
|
t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef())
|
||||||
|
}
|
||||||
|
if ensureCall.GetTargetWalletRef() != sourceWalletRef {
|
||||||
|
t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef())
|
||||||
|
}
|
||||||
|
if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||||
|
t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund")
|
||||||
|
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 != "pay-1:card:gas" {
|
||||||
|
t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -32,7 +32,7 @@ func TestUnarySuccess(t *testing.T) {
|
|||||||
return Success(resp)
|
return Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
unary := Unary[testRequest, testResponse](logger, mservice.Type("test"), handler)
|
unary := Unary(logger, mservice.Type("test"), handler)
|
||||||
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
|
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ enum ChainNetwork {
|
|||||||
CHAIN_NETWORK_UNSPECIFIED = 0;
|
CHAIN_NETWORK_UNSPECIFIED = 0;
|
||||||
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
||||||
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
||||||
CHAIN_NETWORK_OTHER_EVM = 3;
|
|
||||||
CHAIN_NETWORK_TRON_MAINNET = 4;
|
CHAIN_NETWORK_TRON_MAINNET = 4;
|
||||||
CHAIN_NETWORK_TRON_NILE = 5;
|
CHAIN_NETWORK_TRON_NILE = 5;
|
||||||
}
|
}
|
||||||
@@ -101,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 {
|
||||||
@@ -189,6 +189,32 @@ message EstimateTransferFeeResponse {
|
|||||||
string estimation_context = 2;
|
string estimation_context = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ComputeGasTopUpRequest {
|
||||||
|
string wallet_ref = 1;
|
||||||
|
common.money.v1.Money estimated_total_fee = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ComputeGasTopUpResponse {
|
||||||
|
common.money.v1.Money topup_amount = 1;
|
||||||
|
bool cap_hit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EnsureGasTopUpRequest {
|
||||||
|
string idempotency_key = 1;
|
||||||
|
string organization_ref = 2;
|
||||||
|
string source_wallet_ref = 3;
|
||||||
|
string target_wallet_ref = 4;
|
||||||
|
common.money.v1.Money estimated_total_fee = 5;
|
||||||
|
map<string, string> metadata = 6;
|
||||||
|
string client_reference = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EnsureGasTopUpResponse {
|
||||||
|
common.money.v1.Money topup_amount = 1;
|
||||||
|
bool cap_hit = 2;
|
||||||
|
Transfer transfer = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message WalletDepositObservedEvent {
|
message WalletDepositObservedEvent {
|
||||||
string deposit_ref = 1;
|
string deposit_ref = 1;
|
||||||
string wallet_ref = 2;
|
string wallet_ref = 2;
|
||||||
@@ -218,4 +244,6 @@ service ChainGatewayService {
|
|||||||
rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse);
|
rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse);
|
||||||
|
|
||||||
rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse);
|
rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse);
|
||||||
|
rpc ComputeGasTopUp(ComputeGasTopUpRequest) returns (ComputeGasTopUpResponse);
|
||||||
|
rpc EnsureGasTopUp(EnsureGasTopUpRequest) returns (EnsureGasTopUpResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const (
|
|||||||
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
||||||
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
|
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
|
||||||
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
|
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
|
||||||
ChainNetworkOtherEVM ChainNetwork = "other_evm"
|
|
||||||
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
|
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
|
||||||
ChainNetworkTronNile ChainNetwork = "tron_nile"
|
ChainNetworkTronNile ChainNetwork = "tron_nile"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
|
|||||||
t.Run("external chain", func(t *testing.T) {
|
t.Run("external chain", func(t *testing.T) {
|
||||||
payload := ExternalChainEndpoint{
|
payload := ExternalChainEndpoint{
|
||||||
Asset: &Asset{
|
Asset: &Asset{
|
||||||
Chain: ChainNetworkOtherEVM,
|
Chain: ChainNetworkEthereumMainnet,
|
||||||
TokenSymbol: "ETH",
|
TokenSymbol: "ETH",
|
||||||
},
|
},
|
||||||
Address: "0x123",
|
Address: "0x123",
|
||||||
@@ -364,7 +364,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
|
|||||||
func TestLegacyEndpointRoundTrip(t *testing.T) {
|
func TestLegacyEndpointRoundTrip(t *testing.T) {
|
||||||
legacy := &LegacyPaymentEndpoint{
|
legacy := &LegacyPaymentEndpoint{
|
||||||
ExternalChain: &ExternalChainEndpoint{
|
ExternalChain: &ExternalChainEndpoint{
|
||||||
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
|
Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "DAI", ContractAddress: "0xdef"},
|
||||||
Address: "0x123",
|
Address: "0x123",
|
||||||
Memo: "memo",
|
Memo: "memo",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
|
|||||||
token := ""
|
token := ""
|
||||||
contract := ""
|
contract := ""
|
||||||
if asset != nil {
|
if asset != nil {
|
||||||
chain = asset.GetChain().String()
|
chain = chainNetworkValue(asset.GetChain())
|
||||||
token = asset.GetTokenSymbol()
|
token = asset.GetTokenSymbol()
|
||||||
contract = asset.GetContractAddress()
|
contract = asset.GetContractAddress()
|
||||||
}
|
}
|
||||||
@@ -141,3 +141,15 @@ func tsToString(ts *timestamppb.Timestamp) string {
|
|||||||
}
|
}
|
||||||
return ts.AsTime().UTC().Format(time.RFC3339)
|
return ts.AsTime().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chainNetworkValue(chain chainv1.ChainNetwork) string {
|
||||||
|
name := chain.String()
|
||||||
|
if !strings.HasPrefix(name, "CHAIN_NETWORK_") {
|
||||||
|
return "unspecified"
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimPrefix(name, "CHAIN_NETWORK_")
|
||||||
|
if trimmed == "" {
|
||||||
|
return "unspecified"
|
||||||
|
}
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,8 +214,6 @@ func parseChainNetwork(value string) (chainv1.ChainNetwork, error) {
|
|||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||||
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
|
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||||
case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM":
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
|
|
||||||
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
|
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||||
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
|
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
|
||||||
|
|||||||
@@ -286,8 +286,6 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error)
|
|||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||||
case string(srequest.ChainNetworkArbitrumOne):
|
case string(srequest.ChainNetworkArbitrumOne):
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||||
case string(srequest.ChainNetworkOtherEVM):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
|
|
||||||
case string(srequest.ChainNetworkTronMainnet):
|
case string(srequest.ChainNetworkTronMainnet):
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||||
case string(srequest.ChainNetworkTronNile):
|
case string(srequest.ChainNetworkTronNile):
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ String fxSideToValue(FxSide side) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainNetwork chainNetworkFromValue(String? value) {
|
ChainNetwork chainNetworkFromValue(String? value) {
|
||||||
switch (value) {
|
final raw = value ?? '';
|
||||||
|
final normalized = _normalizeChainNetwork(raw);
|
||||||
|
switch (normalized) {
|
||||||
case 'ethereum_mainnet':
|
case 'ethereum_mainnet':
|
||||||
return ChainNetwork.ethereumMainnet;
|
return ChainNetwork.ethereumMainnet;
|
||||||
case 'arbitrum_one':
|
case 'arbitrum_one':
|
||||||
return ChainNetwork.arbitrumOne;
|
return ChainNetwork.arbitrumOne;
|
||||||
case 'other_evm':
|
|
||||||
return ChainNetwork.otherEvm;
|
|
||||||
case 'tron_mainnet':
|
case 'tron_mainnet':
|
||||||
return ChainNetwork.tronMainnet;
|
return ChainNetwork.tronMainnet;
|
||||||
case 'tron_nile':
|
case 'tron_nile':
|
||||||
@@ -97,7 +97,7 @@ ChainNetwork chainNetworkFromValue(String? value) {
|
|||||||
case 'unspecified':
|
case 'unspecified':
|
||||||
return ChainNetwork.unspecified;
|
return ChainNetwork.unspecified;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError('Unknown ChainNetwork value: $value');
|
throw ArgumentError('Unknown ChainNetwork value: $raw');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +107,6 @@ String chainNetworkToValue(ChainNetwork chain) {
|
|||||||
return 'ethereum_mainnet';
|
return 'ethereum_mainnet';
|
||||||
case ChainNetwork.arbitrumOne:
|
case ChainNetwork.arbitrumOne:
|
||||||
return 'arbitrum_one';
|
return 'arbitrum_one';
|
||||||
case ChainNetwork.otherEvm:
|
|
||||||
return 'other_evm';
|
|
||||||
case ChainNetwork.tronMainnet:
|
case ChainNetwork.tronMainnet:
|
||||||
return 'tron_mainnet';
|
return 'tron_mainnet';
|
||||||
case ChainNetwork.tronNile:
|
case ChainNetwork.tronNile:
|
||||||
@@ -118,6 +116,19 @@ String chainNetworkToValue(ChainNetwork chain) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _normalizeChainNetwork(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return 'unspecified';
|
||||||
|
}
|
||||||
|
final lower = trimmed.toLowerCase();
|
||||||
|
const prefix = 'chain_network_';
|
||||||
|
if (lower.startsWith(prefix)) {
|
||||||
|
return lower.substring(prefix.length);
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
|
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'block_posting':
|
case 'block_posting':
|
||||||
|
|||||||
20
frontend/pshared/lib/data/mapper/wallet/asset.dart
Normal file
20
frontend/pshared/lib/data/mapper/wallet/asset.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:pshared/data/dto/wallet/asset.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/enums.dart';
|
||||||
|
import 'package:pshared/models/wallet/asset.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension WalletAssetDTOMapper on WalletAssetDTO {
|
||||||
|
WalletAsset toDomain() => WalletAsset(
|
||||||
|
chain: chainNetworkFromValue(chain),
|
||||||
|
tokenSymbol: tokenSymbol,
|
||||||
|
contractAddress: contractAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WalletAssetMapper on WalletAsset {
|
||||||
|
WalletAssetDTO toDTO() => WalletAssetDTO(
|
||||||
|
chain: chainNetworkToValue(chain),
|
||||||
|
tokenSymbol: tokenSymbol,
|
||||||
|
contractAddress: contractAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/data/mapper/wallet/ui.dart
Normal file
20
frontend/pshared/lib/data/mapper/wallet/ui.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
||||||
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension WalletUiMapper on domain.WalletModel {
|
||||||
|
Wallet toUi() => Wallet(
|
||||||
|
id: walletRef,
|
||||||
|
walletUserID: walletRef,
|
||||||
|
balance: double.tryParse(availableMoney?.amount ?? balance?.available?.amount ?? '0') ?? 0,
|
||||||
|
currency: currencyStringToCode(asset.tokenSymbol),
|
||||||
|
isHidden: true,
|
||||||
|
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
|
||||||
|
depositAddress: depositAddress,
|
||||||
|
network: asset.chain,
|
||||||
|
tokenSymbol: asset.tokenSymbol,
|
||||||
|
contractAddress: asset.contractAddress,
|
||||||
|
describable: describable,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import 'package:pshared/data/dto/wallet/balance.dart';
|
import 'package:pshared/data/dto/wallet/balance.dart';
|
||||||
import 'package:pshared/data/dto/wallet/wallet.dart';
|
import 'package:pshared/data/dto/wallet/wallet.dart';
|
||||||
|
import 'package:pshared/data/mapper/wallet/asset.dart';
|
||||||
import 'package:pshared/data/mapper/wallet/balance.dart';
|
import 'package:pshared/data/mapper/wallet/balance.dart';
|
||||||
import 'package:pshared/data/mapper/wallet/money.dart';
|
import 'package:pshared/data/mapper/wallet/money.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
import 'package:pshared/models/wallet/wallet.dart';
|
import 'package:pshared/models/wallet/wallet.dart';
|
||||||
|
|
||||||
|
|
||||||
extension WalletDTOMapper on WalletDTO {
|
extension WalletDTOMapper on WalletDTO {
|
||||||
WalletModel toDomain({WalletBalanceDTO? balance}) => WalletModel(
|
WalletModel toDomain({WalletBalanceDTO? balance}) => WalletModel(
|
||||||
walletRef: walletRef,
|
walletRef: walletRef,
|
||||||
organizationRef: organizationRef,
|
organizationRef: organizationRef,
|
||||||
ownerRef: ownerRef,
|
ownerRef: ownerRef,
|
||||||
asset: WalletAsset(
|
asset: asset.toDomain(),
|
||||||
chain: asset.chain,
|
|
||||||
tokenSymbol: asset.tokenSymbol,
|
|
||||||
contractAddress: asset.contractAddress,
|
|
||||||
),
|
|
||||||
depositAddress: depositAddress,
|
depositAddress: depositAddress,
|
||||||
status: status,
|
status: status,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
@@ -24,7 +20,7 @@ extension WalletDTOMapper on WalletDTO {
|
|||||||
balance: balance?.toDomain(),
|
balance: balance?.toDomain(),
|
||||||
availableMoney: balance?.available?.toDomain(),
|
availableMoney: balance?.available?.toDomain(),
|
||||||
describable: newDescribable(
|
describable: newDescribable(
|
||||||
name: name.isNotEmpty ? name : (metadata?['name'] ?? 'Crypto Wallet'),
|
name: name.isNotEmpty ? name : (metadata?['name']?.toString() ?? ''),
|
||||||
description: (description != null && description!.isNotEmpty)
|
description: (description != null && description!.isNotEmpty)
|
||||||
? description
|
? description
|
||||||
: metadata?['description'],
|
: metadata?['description'],
|
||||||
|
|||||||
@@ -46,11 +46,6 @@
|
|||||||
"description": "Label for the Arbitrum One network"
|
"description": "Label for the Arbitrum One network"
|
||||||
},
|
},
|
||||||
|
|
||||||
"chainNetworkOtherEvm": "Other EVM chain",
|
|
||||||
"@chainNetworkOtherEvm": {
|
|
||||||
"description": "Label for any other EVM-compatible network"
|
|
||||||
},
|
|
||||||
|
|
||||||
"chainNetworkTronMainnet": "Tron Mainnet",
|
"chainNetworkTronMainnet": "Tron Mainnet",
|
||||||
"@chainNetworkTronMainnet": {
|
"@chainNetworkTronMainnet": {
|
||||||
"description": "Label for the Tron mainnet network"
|
"description": "Label for the Tron mainnet network"
|
||||||
|
|||||||
@@ -46,11 +46,6 @@
|
|||||||
"description": "Label for the Arbitrum One network"
|
"description": "Label for the Arbitrum One network"
|
||||||
},
|
},
|
||||||
|
|
||||||
"chainNetworkOtherEvm": "Другая EVM сеть",
|
|
||||||
"@chainNetworkOtherEvm": {
|
|
||||||
"description": "Label for any other EVM-compatible network"
|
|
||||||
},
|
|
||||||
|
|
||||||
"chainNetworkTronMainnet": "Tron Mainnet",
|
"chainNetworkTronMainnet": "Tron Mainnet",
|
||||||
"@chainNetworkTronMainnet": {
|
"@chainNetworkTronMainnet": {
|
||||||
"description": "Label for the Tron mainnet network"
|
"description": "Label for the Tron mainnet network"
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ class AccountBase implements StorableDescribable {
|
|||||||
DateTime get updatedAt => storable.updatedAt;
|
DateTime get updatedAt => storable.updatedAt;
|
||||||
@override
|
@override
|
||||||
String get name => describable.name;
|
String get name => describable.name;
|
||||||
|
String get fullName {
|
||||||
|
final first = describable.name.trim();
|
||||||
|
final last = lastName.trim();
|
||||||
|
|
||||||
|
if (last.isEmpty) return first;
|
||||||
|
if (first.isEmpty) return last;
|
||||||
|
return '$first $last';
|
||||||
|
}
|
||||||
@override
|
@override
|
||||||
String? get description => describable.description;
|
String? get description => describable.description;
|
||||||
|
|
||||||
@@ -32,7 +40,7 @@ class AccountBase implements StorableDescribable {
|
|||||||
required this.lastName,
|
required this.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get nameInitials => getNameInitials(describable.name);
|
String get nameInitials => getNameInitials(fullName);
|
||||||
|
|
||||||
AccountBase copyWith({
|
AccountBase copyWith({
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ enum ChainNetwork {
|
|||||||
unspecified,
|
unspecified,
|
||||||
ethereumMainnet,
|
ethereumMainnet,
|
||||||
arbitrumOne,
|
arbitrumOne,
|
||||||
otherEvm,
|
|
||||||
tronMainnet,
|
tronMainnet,
|
||||||
tronNile
|
tronNile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:pshared/models/payment/methods/card.dart';
|
|||||||
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/methods/iban.dart';
|
import 'package:pshared/models/payment/methods/iban.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
import 'package:pshared/models/payment/methods/russian_bank.dart';
|
import 'package:pshared/models/payment/methods/russian_bank.dart';
|
||||||
import 'package:pshared/models/payment/methods/wallet.dart';
|
import 'package:pshared/models/payment/methods/wallet.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
@@ -43,6 +44,7 @@ class PaymentMethod implements PermissionBoundStorable, Describable {
|
|||||||
IbanPaymentMethod? get ibanData => dataAsOrNull<IbanPaymentMethod>();
|
IbanPaymentMethod? get ibanData => dataAsOrNull<IbanPaymentMethod>();
|
||||||
RussianBankAccountPaymentMethod? get bankAccountData => dataAsOrNull<RussianBankAccountPaymentMethod>();
|
RussianBankAccountPaymentMethod? get bankAccountData => dataAsOrNull<RussianBankAccountPaymentMethod>();
|
||||||
WalletPaymentMethod? get walletData => dataAsOrNull<WalletPaymentMethod>();
|
WalletPaymentMethod? get walletData => dataAsOrNull<WalletPaymentMethod>();
|
||||||
|
ManagedWalletPaymentMethod? get managedWalletData => dataAsOrNull<ManagedWalletPaymentMethod>();
|
||||||
CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull<CryptoAddressPaymentMethod>();
|
CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull<CryptoAddressPaymentMethod>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:pshared/models/currency.dart';
|
import 'package:pshared/models/currency.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
|
|
||||||
|
|
||||||
class Wallet implements Describable {
|
class Wallet implements Describable {
|
||||||
@@ -10,7 +11,7 @@ class Wallet implements Describable {
|
|||||||
final bool isHidden;
|
final bool isHidden;
|
||||||
final DateTime calculatedAt;
|
final DateTime calculatedAt;
|
||||||
final String? depositAddress;
|
final String? depositAddress;
|
||||||
final String? network;
|
final ChainNetwork? network;
|
||||||
final String? tokenSymbol;
|
final String? tokenSymbol;
|
||||||
final String? contractAddress;
|
final String? contractAddress;
|
||||||
final Describable describable;
|
final Describable describable;
|
||||||
@@ -42,7 +43,7 @@ class Wallet implements Describable {
|
|||||||
String? walletUserID,
|
String? walletUserID,
|
||||||
bool? isHidden,
|
bool? isHidden,
|
||||||
String? depositAddress,
|
String? depositAddress,
|
||||||
String? network,
|
ChainNetwork? network,
|
||||||
String? tokenSymbol,
|
String? tokenSymbol,
|
||||||
String? contractAddress,
|
String? contractAddress,
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
14
frontend/pshared/lib/models/wallet/asset.dart
Normal file
14
frontend/pshared/lib/models/wallet/asset.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class WalletAsset {
|
||||||
|
final ChainNetwork chain;
|
||||||
|
final String tokenSymbol;
|
||||||
|
final String contractAddress;
|
||||||
|
|
||||||
|
const WalletAsset({
|
||||||
|
required this.chain,
|
||||||
|
required this.tokenSymbol,
|
||||||
|
required this.contractAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
|
import 'package:pshared/models/describable.dart';
|
||||||
|
import 'package:pshared/models/wallet/asset.dart';
|
||||||
import 'package:pshared/models/wallet/balance.dart';
|
import 'package:pshared/models/wallet/balance.dart';
|
||||||
import 'package:pshared/models/wallet/money.dart';
|
import 'package:pshared/models/wallet/money.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class WalletAsset {
|
|
||||||
final String chain;
|
|
||||||
final String tokenSymbol;
|
|
||||||
final String contractAddress;
|
|
||||||
|
|
||||||
const WalletAsset({
|
|
||||||
required this.chain,
|
|
||||||
required this.tokenSymbol,
|
|
||||||
required this.contractAddress,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class WalletModel implements Describable {
|
class WalletModel implements Describable {
|
||||||
final String walletRef;
|
final String walletRef;
|
||||||
final String organizationRef;
|
final String organizationRef;
|
||||||
@@ -54,20 +43,18 @@ class WalletModel implements Describable {
|
|||||||
WalletBalance? balance,
|
WalletBalance? balance,
|
||||||
WalletMoney? availableMoney,
|
WalletMoney? availableMoney,
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
}) {
|
}) => WalletModel(
|
||||||
return WalletModel(
|
walletRef: walletRef,
|
||||||
walletRef: walletRef,
|
organizationRef: organizationRef,
|
||||||
organizationRef: organizationRef,
|
ownerRef: ownerRef,
|
||||||
ownerRef: ownerRef,
|
asset: asset,
|
||||||
asset: asset,
|
depositAddress: depositAddress,
|
||||||
depositAddress: depositAddress,
|
status: status,
|
||||||
status: status,
|
metadata: metadata,
|
||||||
metadata: metadata,
|
createdAt: createdAt,
|
||||||
createdAt: createdAt,
|
updatedAt: updatedAt,
|
||||||
updatedAt: updatedAt,
|
balance: balance ?? this.balance,
|
||||||
balance: balance ?? this.balance,
|
availableMoney: availableMoney ?? this.availableMoney,
|
||||||
availableMoney: availableMoney ?? this.availableMoney,
|
describable: describable ?? this.describable,
|
||||||
describable: describable ?? this.describable,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pshared/models/auth/state.dart';
|
|
||||||
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:pshared/api/errors/unauthorized.dart';
|
import 'package:pshared/api/errors/unauthorized.dart';
|
||||||
import 'package:pshared/api/requests/signup.dart';
|
import 'package:pshared/api/requests/signup.dart';
|
||||||
import 'package:pshared/api/requests/login_data.dart';
|
import 'package:pshared/api/requests/login_data.dart';
|
||||||
|
import 'package:pshared/api/responses/confirmation.dart';
|
||||||
import 'package:pshared/config/constants.dart';
|
import 'package:pshared/config/constants.dart';
|
||||||
import 'package:pshared/models/account/account.dart';
|
import 'package:pshared/models/account/account.dart';
|
||||||
import 'package:pshared/api/responses/confirmation.dart';
|
|
||||||
import 'package:pshared/models/auth/login_outcome.dart';
|
import 'package:pshared/models/auth/login_outcome.dart';
|
||||||
import 'package:pshared/models/auth/pending_login.dart';
|
import 'package:pshared/models/auth/pending_login.dart';
|
||||||
|
import 'package:pshared/models/auth/state.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
import 'package:pshared/models/storable.dart';
|
import 'package:pshared/models/storable.dart';
|
||||||
import 'package:pshared/provider/locale.dart';
|
import 'package:pshared/provider/locale.dart';
|
||||||
@@ -203,6 +203,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<Account?> update({
|
Future<Account?> update({
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
|
String? lastName,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
String? notificationFrequency,
|
String? notificationFrequency,
|
||||||
@@ -213,6 +214,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
final updated = await AccountService.update(
|
final updated = await AccountService.update(
|
||||||
account!.copyWith(
|
account!.copyWith(
|
||||||
describable: describable,
|
describable: describable,
|
||||||
|
lastName: lastName,
|
||||||
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
|
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
|
||||||
locale: locale ?? account!.locale,
|
locale: locale ?? account!.locale,
|
||||||
),
|
),
|
||||||
@@ -250,10 +252,11 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Account?> resetUsername(String userName) async {
|
Future<Account?> resetUsername(String userName, {String? lastName}) async {
|
||||||
if (account == null) throw ErrorUnauthorized();
|
if (account == null) throw ErrorUnauthorized();
|
||||||
return update(
|
return update(
|
||||||
describable: account!.describable.copyWith(name: userName),
|
describable: account!.describable.copyWith(name: userName),
|
||||||
|
lastName: lastName ?? account!.lastName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
|
|
||||||
|
|
||||||
class PaymentFlowProvider extends ChangeNotifier {
|
class PaymentFlowProvider extends ChangeNotifier {
|
||||||
PaymentType _selectedType;
|
PaymentType _selectedType;
|
||||||
PaymentMethodData? _manualPaymentData;
|
PaymentMethodData? _manualPaymentData;
|
||||||
|
MethodMap _availableTypes = {};
|
||||||
|
Recipient? _recipient;
|
||||||
|
|
||||||
PaymentFlowProvider({
|
PaymentFlowProvider({
|
||||||
required PaymentType initialType,
|
required PaymentType initialType,
|
||||||
@@ -15,57 +18,40 @@ class PaymentFlowProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
PaymentType get selectedType => _selectedType;
|
PaymentType get selectedType => _selectedType;
|
||||||
PaymentMethodData? get manualPaymentData => _manualPaymentData;
|
PaymentMethodData? get manualPaymentData => _manualPaymentData;
|
||||||
|
Recipient? get recipient => _recipient;
|
||||||
|
|
||||||
void sync({
|
bool get hasRecipient => _recipient != null;
|
||||||
|
|
||||||
|
MethodMap get availableTypes => hasRecipient
|
||||||
|
? _availableTypes
|
||||||
|
: {for (final type in PaymentType.values) type: null};
|
||||||
|
|
||||||
|
PaymentMethodData? get selectedPaymentData =>
|
||||||
|
hasRecipient ? _availableTypes[_selectedType] : _manualPaymentData;
|
||||||
|
|
||||||
|
void syncWith({
|
||||||
required Recipient? recipient,
|
required Recipient? recipient,
|
||||||
required MethodMap availableTypes,
|
required PaymentMethodsProvider methodsProvider,
|
||||||
PaymentType? preferredType,
|
PaymentType? preferredType,
|
||||||
}) {
|
}) =>
|
||||||
final resolvedType = _resolveSelectedType(
|
_applyState(
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
availableTypes: availableTypes,
|
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
|
||||||
preferredType: preferredType,
|
preferredType: preferredType,
|
||||||
);
|
forceResetManualData: false,
|
||||||
|
);
|
||||||
var hasChanges = false;
|
|
||||||
if (resolvedType != _selectedType) {
|
|
||||||
_selectedType = resolvedType;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient != null && _manualPaymentData != null) {
|
|
||||||
_manualPaymentData = null;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChanges) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset({
|
void reset({
|
||||||
required Recipient? recipient,
|
required Recipient? recipient,
|
||||||
required MethodMap availableTypes,
|
required PaymentMethodsProvider methodsProvider,
|
||||||
PaymentType? preferredType,
|
PaymentType? preferredType,
|
||||||
}) {
|
}) =>
|
||||||
final resolvedType = _resolveSelectedType(
|
_applyState(
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
availableTypes: availableTypes,
|
availableTypes: methodsProvider.availableTypesForRecipient(recipient),
|
||||||
preferredType: preferredType,
|
preferredType: preferredType,
|
||||||
);
|
forceResetManualData: true,
|
||||||
|
);
|
||||||
var hasChanges = false;
|
|
||||||
|
|
||||||
if (resolvedType != _selectedType) {
|
|
||||||
_selectedType = resolvedType;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_manualPaymentData != null) {
|
|
||||||
_manualPaymentData = null;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChanges) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void selectType(PaymentType type, {bool resetManualData = false}) {
|
void selectType(PaymentType type, {bool resetManualData = false}) {
|
||||||
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
|
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
|
||||||
@@ -107,4 +93,41 @@ class PaymentFlowProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
return availableTypes.keys.first;
|
return availableTypes.keys.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyState({
|
||||||
|
required Recipient? recipient,
|
||||||
|
required MethodMap availableTypes,
|
||||||
|
required PaymentType? preferredType,
|
||||||
|
required bool forceResetManualData,
|
||||||
|
}) {
|
||||||
|
final resolvedType = _resolveSelectedType(
|
||||||
|
recipient: recipient,
|
||||||
|
availableTypes: availableTypes,
|
||||||
|
preferredType: preferredType,
|
||||||
|
);
|
||||||
|
|
||||||
|
var hasChanges = false;
|
||||||
|
|
||||||
|
if (_recipient != recipient) {
|
||||||
|
_recipient = recipient;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapEquals(_availableTypes, availableTypes)) {
|
||||||
|
_availableTypes = availableTypes;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedType != _selectedType) {
|
||||||
|
_selectedType = resolvedType;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((recipient != null || forceResetManualData) && _manualPaymentData != null) {
|
||||||
|
_manualPaymentData = null;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pshared/models/asset.dart';
|
|
||||||
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/api/requests/payment/quote.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/intent/payment.dart';
|
||||||
|
import 'package:pshared/models/asset.dart';
|
||||||
import 'package:pshared/models/payment/currency_pair.dart';
|
import 'package:pshared/models/payment/currency_pair.dart';
|
||||||
import 'package:pshared/models/payment/fx/intent.dart';
|
import 'package:pshared/models/payment/fx/intent.dart';
|
||||||
import 'package:pshared/models/payment/fx/side.dart';
|
import 'package:pshared/models/payment/fx/side.dart';
|
||||||
import 'package:pshared/models/payment/kind.dart';
|
import 'package:pshared/models/payment/kind.dart';
|
||||||
import 'package:pshared/models/payment/methods/card.dart';
|
|
||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
import 'package:pshared/models/payment/money.dart';
|
import 'package:pshared/models/payment/money.dart';
|
||||||
import 'package:pshared/models/payment/settlement_mode.dart';
|
import 'package:pshared/models/payment/settlement_mode.dart';
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
|
||||||
import 'package:pshared/api/requests/payment/quote.dart';
|
|
||||||
import 'package:pshared/data/mapper/payment/intent/payment.dart';
|
|
||||||
import 'package:pshared/models/payment/intent.dart';
|
import 'package:pshared/models/payment/intent.dart';
|
||||||
import 'package:pshared/models/payment/quote.dart';
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
|
import 'package:pshared/provider/payment/flow.dart';
|
||||||
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
import 'package:pshared/provider/resource.dart';
|
import 'package:pshared/provider/resource.dart';
|
||||||
import 'package:pshared/service/payment/quotation.dart';
|
import 'package:pshared/service/payment/quotation.dart';
|
||||||
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
|
|
||||||
class QuotationProvider extends ChangeNotifier {
|
class QuotationProvider extends ChangeNotifier {
|
||||||
@@ -26,31 +28,36 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
late OrganizationsProvider _organizations;
|
late OrganizationsProvider _organizations;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
void update(OrganizationsProvider venue, PaymentAmountProvider payment) {
|
void update(
|
||||||
|
OrganizationsProvider venue,
|
||||||
|
PaymentAmountProvider payment,
|
||||||
|
WalletsProvider wallets,
|
||||||
|
PaymentFlowProvider flow,
|
||||||
|
) {
|
||||||
_organizations = venue;
|
_organizations = venue;
|
||||||
getQuotation(PaymentIntent(
|
final destination = flow.selectedPaymentData;
|
||||||
kind: PaymentKind.payout,
|
if ((wallets.selectedWallet != null) && (destination != null)) {
|
||||||
amount: Money(
|
getQuotation(PaymentIntent(
|
||||||
amount: payment.amount.toString(),
|
kind: PaymentKind.payout,
|
||||||
currency: 'USDT',
|
amount: Money(
|
||||||
),
|
amount: payment.amount.toString(),
|
||||||
destination: CardPaymentMethod(
|
// TODO: adapt to possible other sources
|
||||||
pan: '4000000000000077',
|
currency: currencyCodeToString(wallets.selectedWallet!.currency),
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe',
|
|
||||||
),
|
|
||||||
source: ManagedWalletPaymentMethod(
|
|
||||||
managedWalletRef: '',
|
|
||||||
),
|
|
||||||
fx: FxIntent(
|
|
||||||
pair: CurrencyPair(
|
|
||||||
base: 'USDT',
|
|
||||||
quote: 'RUB',
|
|
||||||
),
|
),
|
||||||
side: FxSide.sellBaseBuyQuote,
|
destination: destination,
|
||||||
),
|
source: ManagedWalletPaymentMethod(
|
||||||
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
managedWalletRef: wallets.selectedWallet!.id,
|
||||||
));
|
),
|
||||||
|
fx: FxIntent(
|
||||||
|
pair: CurrencyPair(
|
||||||
|
base: currencyCodeToString(wallets.selectedWallet!.currency),
|
||||||
|
quote: 'RUB', // TODO: exentd target currencies
|
||||||
|
),
|
||||||
|
side: FxSide.sellBaseBuyQuote,
|
||||||
|
),
|
||||||
|
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PaymentQuote? get quotation => _quotation.data;
|
PaymentQuote? get quotation => _quotation.data;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
import 'package:pshared/provider/resource.dart';
|
import 'package:pshared/provider/resource.dart';
|
||||||
|
import 'package:pshared/service/payment/wallets.dart';
|
||||||
import 'package:pshared/utils/exception.dart';
|
import 'package:pshared/utils/exception.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
|
||||||
import 'package:pweb/services/wallets.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class WalletsProvider with ChangeNotifier {
|
class WalletsProvider with ChangeNotifier {
|
||||||
@@ -5,6 +5,8 @@ import 'package:pshared/models/describable.dart';
|
|||||||
import 'package:pshared/models/organization/bound.dart';
|
import 'package:pshared/models/organization/bound.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/methods/type.dart';
|
import 'package:pshared/models/payment/methods/type.dart';
|
||||||
|
import 'package:pshared/models/payment/type.dart';
|
||||||
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/models/permissions/bound.dart';
|
import 'package:pshared/models/permissions/bound.dart';
|
||||||
import 'package:pshared/models/storable.dart';
|
import 'package:pshared/models/storable.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
@@ -20,6 +22,24 @@ class PaymentMethodsProvider extends GenericProvider<PaymentMethod> {
|
|||||||
|
|
||||||
List<PaymentMethod> get methods => List<PaymentMethod>.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)));
|
List<PaymentMethod> get methods => List<PaymentMethod>.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt)));
|
||||||
|
|
||||||
|
List<PaymentMethod> methodsForRecipient(Recipient? recipient) {
|
||||||
|
if (recipient == null || !isReady) return [];
|
||||||
|
|
||||||
|
return methods
|
||||||
|
.where((method) => !method.isArchived && method.recipientRef == recipient.id)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
MethodMap availableTypesForRecipient(Recipient? recipient) => {
|
||||||
|
for (final method in methodsForRecipient(recipient)) method.type: method.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
PaymentMethod? findMethodByType({
|
||||||
|
required PaymentType type,
|
||||||
|
required Recipient? recipient,
|
||||||
|
}) =>
|
||||||
|
methodsForRecipient(recipient).firstWhereOrNull((method) => method.type == type);
|
||||||
|
|
||||||
void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) {
|
void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) {
|
||||||
if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id);
|
if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import 'package:pshared/data/mapper/wallet/ui.dart';
|
||||||
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
|
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
|
||||||
import 'package:pweb/data/mappers/wallet_ui.dart';
|
|
||||||
|
|
||||||
|
|
||||||
abstract class WalletsService {
|
abstract class WalletsService {
|
||||||
Future<List<Wallet>> getWallets(String organizationRef);
|
Future<List<Wallet>> getWallets(String organizationRef);
|
||||||
@@ -48,6 +48,21 @@ Currency currencyStringToCode(String currencyCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String currencyCodeToString(Currency currencyCode) {
|
||||||
|
switch (currencyCode) {
|
||||||
|
case Currency.usd:
|
||||||
|
return 'USD';
|
||||||
|
case Currency.usdt:
|
||||||
|
return 'USDT';
|
||||||
|
case Currency.usdc:
|
||||||
|
return 'USDC';
|
||||||
|
case Currency.rub:
|
||||||
|
return 'RUB';
|
||||||
|
case Currency.eur:
|
||||||
|
return 'EUR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconData iconForCurrencyType(Currency currencyCode) {
|
IconData iconForCurrencyType(Currency currencyCode) {
|
||||||
switch (currencyCode) {
|
switch (currencyCode) {
|
||||||
case Currency.usd:
|
case Currency.usd:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'package:pshared/generated/i18n/ps_localizations.dart';
|
|
||||||
import 'package:pshared/models/payment/chain_network.dart';
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/generated/i18n/ps_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Localized labels for [ChainNetwork] values.
|
/// Localized labels for [ChainNetwork] values.
|
||||||
extension ChainNetworkL10n on ChainNetwork {
|
extension ChainNetworkL10n on ChainNetwork {
|
||||||
/// Returns a human-readable, localized name for the chain.
|
/// Returns a human-readable, localized name for the chain.
|
||||||
@@ -13,8 +15,6 @@ extension ChainNetworkL10n on ChainNetwork {
|
|||||||
return l10n.chainNetworkEthereumMainnet;
|
return l10n.chainNetworkEthereumMainnet;
|
||||||
case ChainNetwork.arbitrumOne:
|
case ChainNetwork.arbitrumOne:
|
||||||
return l10n.chainNetworkArbitrumOne;
|
return l10n.chainNetworkArbitrumOne;
|
||||||
case ChainNetwork.otherEvm:
|
|
||||||
return l10n.chainNetworkOtherEvm;
|
|
||||||
case ChainNetwork.tronMainnet:
|
case ChainNetwork.tronMainnet:
|
||||||
return l10n.chainNetworkTronMainnet;
|
return l10n.chainNetworkTronMainnet;
|
||||||
case ChainNetwork.tronNile:
|
case ChainNetwork.tronNile:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class ConfirmPasswordField extends StatelessWidget {
|
|||||||
required this.newPasswordController,
|
required this.newPasswordController,
|
||||||
required this.missingPasswordError,
|
required this.missingPasswordError,
|
||||||
required this.passwordsDoNotMatchError,
|
required this.passwordsDoNotMatchError,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.onToggleVisibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
@@ -19,6 +21,8 @@ class ConfirmPasswordField extends StatelessWidget {
|
|||||||
final TextEditingController newPasswordController;
|
final TextEditingController newPasswordController;
|
||||||
final String missingPasswordError;
|
final String missingPasswordError;
|
||||||
final String passwordsDoNotMatchError;
|
final String passwordsDoNotMatchError;
|
||||||
|
final bool obscureText;
|
||||||
|
final VoidCallback onToggleVisibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -26,11 +30,18 @@ class ConfirmPasswordField extends StatelessWidget {
|
|||||||
width: fieldWidth,
|
width: fieldWidth,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
obscureText: true,
|
obscureText: obscureText,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: confirmPasswordLabel,
|
labelText: confirmPasswordLabel,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) return missingPasswordError;
|
if (value == null || value.isEmpty) return missingPasswordError;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class PasswordField extends StatelessWidget {
|
|||||||
required this.labelText,
|
required this.labelText,
|
||||||
required this.fieldWidth,
|
required this.fieldWidth,
|
||||||
required this.isEnabled,
|
required this.isEnabled,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.onToggleVisibility,
|
||||||
required this.validator,
|
required this.validator,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ class PasswordField extends StatelessWidget {
|
|||||||
final String labelText;
|
final String labelText;
|
||||||
final double fieldWidth;
|
final double fieldWidth;
|
||||||
final bool isEnabled;
|
final bool isEnabled;
|
||||||
|
final bool obscureText;
|
||||||
|
final VoidCallback onToggleVisibility;
|
||||||
final String? Function(String?) validator;
|
final String? Function(String?) validator;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,11 +26,18 @@ class PasswordField extends StatelessWidget {
|
|||||||
width: fieldWidth,
|
width: fieldWidth,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
obscureText: true,
|
obscureText: obscureText,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: labelText,
|
labelText: labelText,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
validator: validator,
|
validator: validator,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class PasswordFields extends StatelessWidget {
|
|||||||
required this.fieldWidth,
|
required this.fieldWidth,
|
||||||
required this.gapSmall,
|
required this.gapSmall,
|
||||||
required this.isEnabled,
|
required this.isEnabled,
|
||||||
|
required this.showOldPassword,
|
||||||
|
required this.showNewPassword,
|
||||||
|
required this.showConfirmPassword,
|
||||||
|
required this.onToggleOldPassword,
|
||||||
|
required this.onToggleNewPassword,
|
||||||
|
required this.onToggleConfirmPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController oldPasswordController;
|
final TextEditingController oldPasswordController;
|
||||||
@@ -31,6 +37,12 @@ class PasswordFields extends StatelessWidget {
|
|||||||
final double fieldWidth;
|
final double fieldWidth;
|
||||||
final double gapSmall;
|
final double gapSmall;
|
||||||
final bool isEnabled;
|
final bool isEnabled;
|
||||||
|
final bool showOldPassword;
|
||||||
|
final bool showNewPassword;
|
||||||
|
final bool showConfirmPassword;
|
||||||
|
final VoidCallback onToggleOldPassword;
|
||||||
|
final VoidCallback onToggleNewPassword;
|
||||||
|
final VoidCallback onToggleConfirmPassword;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -41,6 +53,8 @@ class PasswordFields extends StatelessWidget {
|
|||||||
labelText: oldPasswordLabel,
|
labelText: oldPasswordLabel,
|
||||||
fieldWidth: fieldWidth,
|
fieldWidth: fieldWidth,
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
|
obscureText: !showOldPassword,
|
||||||
|
onToggleVisibility: onToggleOldPassword,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
(value == null || value.isEmpty) ? missingPasswordError : null,
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
),
|
),
|
||||||
@@ -50,6 +64,8 @@ class PasswordFields extends StatelessWidget {
|
|||||||
labelText: newPasswordLabel,
|
labelText: newPasswordLabel,
|
||||||
fieldWidth: fieldWidth,
|
fieldWidth: fieldWidth,
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
|
obscureText: !showNewPassword,
|
||||||
|
onToggleVisibility: onToggleNewPassword,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
(value == null || value.isEmpty) ? missingPasswordError : null,
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
),
|
),
|
||||||
@@ -62,6 +78,8 @@ class PasswordFields extends StatelessWidget {
|
|||||||
newPasswordController: newPasswordController,
|
newPasswordController: newPasswordController,
|
||||||
missingPasswordError: missingPasswordError,
|
missingPasswordError: missingPasswordError,
|
||||||
passwordsDoNotMatchError: passwordsDoNotMatchError,
|
passwordsDoNotMatchError: passwordsDoNotMatchError,
|
||||||
|
obscureText: !showConfirmPassword,
|
||||||
|
onToggleVisibility: onToggleConfirmPassword,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:pshared/provider/recipient/provider.dart';
|
|||||||
|
|
||||||
import 'package:pweb/app/router/pages.dart';
|
import 'package:pweb/app/router/pages.dart';
|
||||||
import 'package:pweb/app/router/payout_routes.dart';
|
import 'package:pweb/app/router/payout_routes.dart';
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/address_book/form/page.dart';
|
import 'package:pweb/pages/address_book/form/page.dart';
|
||||||
import 'package:pweb/pages/address_book/page/page.dart';
|
import 'package:pweb/pages/address_book/page/page.dart';
|
||||||
import 'package:pweb/pages/dashboard/dashboard.dart';
|
import 'package:pweb/pages/dashboard/dashboard.dart';
|
||||||
@@ -20,7 +20,7 @@ import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
|
|||||||
import 'package:pweb/pages/report/page.dart';
|
import 'package:pweb/pages/report/page.dart';
|
||||||
import 'package:pweb/pages/settings/profile/page.dart';
|
import 'package:pweb/pages/settings/profile/page.dart';
|
||||||
import 'package:pweb/pages/wallet_top_up/page.dart';
|
import 'package:pweb/pages/wallet_top_up/page.dart';
|
||||||
import 'package:pweb/providers/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
import 'package:pweb/widgets/error/snackbar.dart';
|
import 'package:pweb/widgets/error/snackbar.dart';
|
||||||
import 'package:pweb/widgets/sidebar/destinations.dart';
|
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||||
import 'package:pweb/widgets/sidebar/page.dart';
|
import 'package:pweb/widgets/sidebar/page.dart';
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
|
||||||
|
|
||||||
import 'package:pshared/models/currency.dart';
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
|
||||||
|
|
||||||
|
|
||||||
extension WalletUiMapper on domain.WalletModel {
|
|
||||||
Wallet toUi() {
|
|
||||||
final amountStr = availableMoney?.amount ?? balance?.available?.amount ?? '0';
|
|
||||||
final currencyStr = availableMoney?.currency ?? balance?.available?.currency ?? Currency.usd.toString().toUpperCase();
|
|
||||||
final parsedAmount = double.tryParse(amountStr) ?? 0;
|
|
||||||
final currency = Currency.values.firstWhere(
|
|
||||||
(c) => c.name.toUpperCase() == currencyStr.toUpperCase(),
|
|
||||||
orElse: () => Currency.usd,
|
|
||||||
);
|
|
||||||
return Wallet(
|
|
||||||
id: walletRef,
|
|
||||||
walletUserID: walletRef,
|
|
||||||
balance: parsedAmount,
|
|
||||||
currency: currency,
|
|
||||||
isHidden: true,
|
|
||||||
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
|
|
||||||
depositAddress: depositAddress,
|
|
||||||
network: asset.chain,
|
|
||||||
tokenSymbol: asset.tokenSymbol,
|
|
||||||
contractAddress: asset.contractAddress,
|
|
||||||
describable: describable,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,10 @@ import 'package:pshared/provider/permissions.dart';
|
|||||||
import 'package:pshared/provider/account.dart';
|
import 'package:pshared/provider/account.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
import 'package:pshared/provider/payment/quotation.dart';
|
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
import 'package:pshared/service/payment/wallets.dart';
|
||||||
|
|
||||||
import 'package:pweb/app/app.dart';
|
import 'package:pweb/app/app.dart';
|
||||||
import 'package:pweb/app/timeago.dart';
|
import 'package:pweb/app/timeago.dart';
|
||||||
@@ -24,13 +25,11 @@ import 'package:pweb/providers/mock_payment.dart';
|
|||||||
import 'package:pweb/providers/operatioins.dart';
|
import 'package:pweb/providers/operatioins.dart';
|
||||||
import 'package:pweb/providers/two_factor.dart';
|
import 'package:pweb/providers/two_factor.dart';
|
||||||
import 'package:pweb/providers/upload_history.dart';
|
import 'package:pweb/providers/upload_history.dart';
|
||||||
import 'package:pweb/providers/wallets.dart';
|
|
||||||
import 'package:pweb/providers/wallet_transactions.dart';
|
import 'package:pweb/providers/wallet_transactions.dart';
|
||||||
import 'package:pweb/services/operations.dart';
|
import 'package:pweb/services/operations.dart';
|
||||||
import 'package:pweb/services/payments/history.dart';
|
import 'package:pweb/services/payments/history.dart';
|
||||||
import 'package:pweb/services/posthog.dart';
|
import 'package:pweb/services/posthog.dart';
|
||||||
import 'package:pweb/services/wallet_transactions.dart';
|
import 'package:pweb/services/wallet_transactions.dart';
|
||||||
import 'package:pweb/services/wallets.dart';
|
|
||||||
import 'package:pweb/providers/account.dart';
|
import 'package:pweb/providers/account.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -100,10 +99,6 @@ void main() async {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => PaymentAmountProvider(),
|
create: (_) => PaymentAmountProvider(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, PaymentAmountProvider, QuotationProvider>(
|
|
||||||
create: (_) => QuotationProvider(),
|
|
||||||
update: (context, orgnization, payment, provider) => provider!..update(orgnization, payment),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: const PayApp(),
|
child: const PayApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
1
frontend/pweb/lib/models/flow_status.dart
Normal file
1
frontend/pweb/lib/models/flow_status.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum FlowStatus { idle, submitting, resending, success, error }
|
||||||
1
frontend/pweb/lib/models/password_field_type.dart
Normal file
1
frontend/pweb/lib/models/password_field_type.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum PasswordFieldType { old, newPassword, confirmPassword }
|
||||||
1
frontend/pweb/lib/models/visibility.dart
Normal file
1
frontend/pweb/lib/models/visibility.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum VisibilityState { hidden, visible }
|
||||||
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
|
||||||
import 'package:pweb/providers/two_factor.dart';
|
import 'package:pweb/providers/two_factor.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class ResendCodeButton extends StatelessWidget {
|
class ResendCodeButton extends StatelessWidget {
|
||||||
const ResendCodeButton({super.key});
|
const ResendCodeButton({super.key});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/status.dart';
|
import 'package:pshared/models/recipient/status.dart';
|
||||||
import 'package:pshared/models/recipient/type.dart';
|
import 'package:pshared/models/recipient/type.dart';
|
||||||
|
|
||||||
import 'package:pweb/utils/payment/label.dart';
|
|
||||||
import 'package:pweb/pages/address_book/form/method_tile.dart';
|
import 'package:pweb/pages/address_book/form/method_tile.dart';
|
||||||
import 'package:pweb/pages/address_book/form/widgets/button.dart';
|
import 'package:pweb/pages/address_book/form/widgets/button.dart';
|
||||||
import 'package:pweb/pages/address_book/form/widgets/email_field.dart';
|
import 'package:pweb/pages/address_book/form/widgets/email_field.dart';
|
||||||
import 'package:pweb/pages/address_book/form/widgets/header.dart';
|
import 'package:pweb/pages/address_book/form/widgets/header.dart';
|
||||||
import 'package:pweb/pages/address_book/form/widgets/name_field.dart';
|
import 'package:pweb/pages/address_book/form/widgets/name_field.dart';
|
||||||
|
import 'package:pweb/utils/payment/label.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
|
|
||||||
|
|
||||||
class BalanceAmount extends StatelessWidget {
|
class BalanceAmount extends StatelessWidget {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
|
||||||
import 'package:pweb/providers/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||||
import 'package:pweb/providers/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
|
|
||||||
class WalletCard extends StatelessWidget {
|
class WalletCard extends StatelessWidget {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
|
import 'package:pshared/utils/l10n/chain.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class BalanceHeader extends StatelessWidget {
|
class BalanceHeader extends StatelessWidget {
|
||||||
final String? walletNetwork;
|
final ChainNetwork? walletNetwork;
|
||||||
final String? tokenSymbol;
|
final String? tokenSymbol;
|
||||||
|
|
||||||
const BalanceHeader({
|
const BalanceHeader({
|
||||||
@@ -15,14 +19,33 @@ class BalanceHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
final symbol = tokenSymbol?.trim();
|
final symbol = tokenSymbol?.trim();
|
||||||
|
final networkLabel = (walletNetwork == null || walletNetwork == ChainNetwork.unspecified)
|
||||||
|
? null
|
||||||
|
: walletNetwork!.localizedName(context);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
'Crypto Wallet',
|
child: Column(
|
||||||
style: textTheme.titleMedium?.copyWith(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: colorScheme.onSurface,
|
children: [
|
||||||
|
Text(
|
||||||
|
loc.paymentTypeCryptoWallet,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (networkLabel != null)
|
||||||
|
Text(
|
||||||
|
networkLabel,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (symbol != null && symbol.isNotEmpty) ...[
|
if (symbol != null && symbol.isNotEmpty) ...[
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/buttons.dart';
|
import 'package:pweb/pages/dashboard/buttons/buttons.dart';
|
||||||
import 'package:pweb/pages/dashboard/payouts/multiple/title.dart';
|
import 'package:pweb/pages/dashboard/payouts/multiple/title.dart';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
|
|||||||
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
|
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
|
||||||
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
|
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
|
||||||
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
|
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
|
||||||
import 'package:pweb/providers/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
|
|
||||||
class PaymentSummary extends StatelessWidget {
|
class PaymentSummary extends StatelessWidget {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
|
import 'package:pshared/provider/payment/flow.dart';
|
||||||
import 'package:pshared/provider/payment/quotation.dart';
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
||||||
|
|
||||||
@@ -18,9 +20,9 @@ class PaymentFromWrappingWidget extends StatelessWidget {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => PaymentAmountProvider(),
|
create: (_) => PaymentAmountProvider(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, PaymentAmountProvider, QuotationProvider>(
|
ChangeNotifierProxyProvider4<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, QuotationProvider>(
|
||||||
create: (_) => QuotationProvider(),
|
create: (_) => QuotationProvider(),
|
||||||
update: (context, orgnization, payment, provider) => provider!..update(orgnization, payment),
|
update: (context, orgnization, payment, wallet, flow, provider) => provider!..update(orgnization, payment, wallet, flow),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const PaymentFormWidget(),
|
child: const PaymentFormWidget(),
|
||||||
|
|||||||
@@ -67,10 +67,17 @@ class _CardFormMinimalState extends State<CardFormMinimal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newData != null && newData != oldData) {
|
if (newData != null && newData != oldData) {
|
||||||
_panController.text = newData.pan;
|
final hasPanChange = newData.pan != _panController.text;
|
||||||
_firstNameController.text = newData.firstName;
|
final hasFirstNameChange = newData.firstName != _firstNameController.text;
|
||||||
_lastNameController.text = newData.lastName;
|
final hasLastNameChange = newData.lastName != _lastNameController.text;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
|
|
||||||
|
if (hasPanChange) _panController.text = newData.pan;
|
||||||
|
if (hasFirstNameChange) _firstNameController.text = newData.firstName;
|
||||||
|
if (hasLastNameChange) _lastNameController.text = newData.lastName;
|
||||||
|
|
||||||
|
if (hasPanChange || hasFirstNameChange || hasLastNameChange) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user