9 Commits

Author SHA1 Message Date
Arseni
3ddd7718c2 Moved payment data preparation into providers 2025-12-25 19:24:45 +03:00
b96babdfd4 Merge pull request 'new mntx funding wallet address' (#165) from mntx-164 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #165
2025-12-25 13:19:17 +00:00
69fdbf4e95 Merge pull request 'Fixed payment information form in address recipient book and fixed some headers' (#163) from SEND016 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #163
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-25 13:18:37 +00:00
Stephan D
d32b2aa959 new mntx funding wallet address 2025-12-25 14:18:18 +01:00
Arseni
be10839e3a Fixed payment information form in address recipient book and fixed some headers 2025-12-25 15:10:20 +03:00
d530af43a1 Merge pull request 'EVM, ARB, ETH gas top up policies + tron config change' (#162) from tron-161 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #162
2025-12-25 11:54:02 +00:00
Stephan D
aa673fb26d EVM, ARB, ETH gas top up policies + tron config change 2025-12-25 12:52:34 +01:00
d978e24a9d Merge pull request 'Gas topup limits' (#160) from tron-159 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
Reviewed-on: #160
2025-12-25 11:29:45 +00:00
Stephan D
31d93e5113 Gas topup limits 2025-12-25 12:26:24 +01:00
44 changed files with 1381 additions and 302 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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"

View File

@@ -8,6 +8,7 @@ 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"
@@ -46,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 {
@@ -66,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{
@@ -217,17 +232,86 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
}) })
} }
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
if err != nil {
logger.Error("invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
return nil, err
}
result = append(result, gatewayshared.Network{ 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 != "" {

View File

@@ -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")),
} }
} }

View File

@@ -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
}

View File

@@ -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)
} }
tokenBalance, nativeBalance, chainErr := onChainWalletBalances(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)

View File

@@ -12,7 +12,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func onChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) { func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
logger := deps.Logger logger := deps.Logger
if wallet == nil { if wallet == nil {
return nil, nil, merrors.InvalidArgument("wallet is required") return nil, nil, merrors.InvalidArgument("wallet is required")

View File

@@ -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
}

View File

@@ -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),
}
}

View File

@@ -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"
}

View File

@@ -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),
}
}

View File

@@ -126,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

View File

@@ -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
}

View File

@@ -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.

View File

@@ -59,7 +59,7 @@ oracle:
card_gateways: card_gateways:
monetix: monetix:
funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8" funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
fee_wallet_ref: "694c124fd76f9f811ac57134" fee_wallet_ref: "694c124fd76f9f811ac57134"
fee_ledger_accounts: fee_ledger_accounts:

View File

@@ -117,79 +117,58 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
} }
} }
requiredGas, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
if err != nil { if err != nil {
return err return err
} }
balanceResp, err := s.deps.gateway.client.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{ var estimatedTotalFee *moneyv1.Money
WalletRef: sourceWalletRef, if gasCurrency != "" && !totalFee.IsNegative() {
}) estimatedTotalFee = makeMoney(gasCurrency, totalFee)
if err != nil {
s.logger.Warn("card funding balance check failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if balanceResp == nil {
return merrors.Internal("card funding: balance unavailable")
}
var nativeAvailable *moneyv1.Money
if balance := balanceResp.GetBalance(); balance != nil {
nativeAvailable = balance.GetNativeAvailable()
}
available := decimal.Zero
availableCurrency := ""
if nativeAvailable != nil && strings.TrimSpace(nativeAvailable.GetAmount()) != "" {
if strings.TrimSpace(nativeAvailable.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: native balance currency is required")
}
available, err = decimalFromMoney(nativeAvailable)
if err != nil {
return err
}
availableCurrency = strings.TrimSpace(nativeAvailable.GetCurrency())
}
if requiredGas.IsPositive() {
if availableCurrency == "" {
availableCurrency = gasCurrency
}
if gasCurrency != "" && availableCurrency != "" && !strings.EqualFold(gasCurrency, availableCurrency) {
return merrors.InvalidArgument("card funding: native balance currency mismatch")
}
}
topUpAmount := decimal.Zero
if requiredGas.IsPositive() {
topUpAmount = requiredGas.Sub(available)
if topUpAmount.IsNegative() {
topUpAmount = decimal.Zero
}
} }
var topUpMoney *moneyv1.Money var topUpMoney *moneyv1.Money
var topUpFee *moneyv1.Money var topUpFee *moneyv1.Money
if topUpAmount.IsPositive() { topUpPositive := false
if feeWalletRef == "" { if estimatedTotalFee != nil {
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
} WalletRef: sourceWalletRef,
if gasCurrency == "" { EstimatedTotalFee: estimatedTotalFee,
gasCurrency = availableCurrency })
}
if gasCurrency == "" {
return merrors.InvalidArgument("card funding: native currency is required for gas top-up")
}
topUpMoney = makeMoney(gasCurrency, topUpAmount)
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
if err != nil { if err != nil {
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err 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) plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep var gasStep *model.ExecutionStep
if topUpMoney != nil && topUpAmount.IsPositive() { if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
gasStep.Description = "Top up native gas from fee wallet" gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = cloneMoney(topUpMoney) gasStep.Amount = cloneMoney(topUpMoney)
@@ -230,27 +209,58 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
exec = &model.ExecutionRefs{} exec = &model.ExecutionRefs{}
} }
if topUpMoney != nil && topUpAmount.IsPositive() { if topUpMoney != nil && topUpPositive {
gasReq := &chainv1.SubmitTransferRequest{ ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas", IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(), OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef, SourceWalletRef: feeWalletRef,
Destination: &chainv1.TransferDestination{ TargetWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, EstimatedTotalFee: estimatedTotalFee,
}, Metadata: cloneMetadata(payment.Metadata),
Amount: topUpMoney, ClientReference: payment.PaymentRef,
Metadata: cloneMetadata(payment.Metadata), })
ClientReference: payment.PaymentRef,
}
gasResp, gasErr := s.deps.gateway.client.SubmitTransfer(ctx, gasReq)
if gasErr != nil { if gasErr != nil {
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
return gasErr return gasErr
} }
if gasResp != nil && gasResp.GetTransfer() != nil { if gasStep != nil {
gasStep.TransferRef = strings.TrimSpace(gasResp.GetTransfer().GetTransferRef()) actual := (*moneyv1.Money)(nil)
if ensureResp != nil {
actual = ensureResp.GetTopupAmount()
if transfer := ensureResp.GetTransfer(); transfer != nil {
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
}
}
actualPositive := false
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
actualDec, err := decimalFromMoney(actual)
if err != nil {
return err
}
actualPositive = actualDec.IsPositive()
}
if actual != nil && actualPositive {
gasStep.Amount = cloneMoney(actual)
if strings.TrimSpace(actual.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = cloneMoney(topUpFee)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
}
} }
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) if gasStep != nil {
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
}
updateExecutionPlanTotalNetworkFee(plan)
} }
// Transfer payout amount to funding wallet. // Transfer payout amount to funding wallet.

View File

@@ -27,6 +27,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
) )
var estimateCalls []*chainv1.EstimateTransferFeeRequest var estimateCalls []*chainv1.EstimateTransferFeeRequest
var computeCalls []*chainv1.ComputeGasTopUpRequest
var ensureCalls []*chainv1.EnsureGasTopUpRequest
var submitCalls []*chainv1.SubmitTransferRequest var submitCalls []*chainv1.SubmitTransferRequest
gateway := &chainclient.Fake{ gateway := &chainclient.Fake{
@@ -47,11 +49,17 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"}, NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"},
}, nil }, nil
}, },
GetWalletBalanceFn: func(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
return &chainv1.GetWalletBalanceResponse{ computeCalls = append(computeCalls, req)
Balance: &chainv1.WalletBalance{ return &chainv1.ComputeGasTopUpResponse{
NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.005"}, 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 }, nil
}, },
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
@@ -105,22 +113,36 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
t.Fatalf("submitCardFundingTransfers error: %v", err) t.Fatalf("submitCardFundingTransfers error: %v", err)
} }
if len(estimateCalls) != 3 { if len(estimateCalls) != 4 {
t.Fatalf("expected 3 fee estimates, got %d", len(estimateCalls)) t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls))
} }
if len(submitCalls) != 2 { if len(computeCalls) != 1 {
t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls)) t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls))
}
if len(ensureCalls) != 1 {
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
}
if len(submitCalls) != 1 {
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
} }
gasCall := findSubmitCall(t, submitCalls, "pay-1:card:gas") computeCall := computeCalls[0]
if gasCall.GetSourceWalletRef() != feeWalletRef { if computeCall.GetWalletRef() != sourceWalletRef {
t.Fatalf("gas top-up source wallet mismatch: %s", gasCall.GetSourceWalletRef()) t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef())
} }
if gasCall.GetDestination().GetManagedWalletRef() != sourceWalletRef { if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
t.Fatalf("gas top-up destination mismatch: %s", gasCall.GetDestination().GetManagedWalletRef()) t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount())
} }
if gasCall.GetAmount().GetCurrency() != "ETH" || gasCall.GetAmount().GetAmount() != "0.025" {
t.Fatalf("gas top-up amount mismatch: %s %s", gasCall.GetAmount().GetCurrency(), gasCall.GetAmount().GetAmount()) ensureCall := ensureCalls[0]
if ensureCall.GetSourceWalletRef() != feeWalletRef {
t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef())
}
if ensureCall.GetTargetWalletRef() != sourceWalletRef {
t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef())
}
if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount())
} }
fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund") fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund")
@@ -146,8 +168,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" { if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" {
t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount()) t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount())
} }
if gasStep.TransferRef == "" { if gasStep.TransferRef != "pay-1:card:gas" {
t.Fatalf("expected gas step transfer ref to be set") t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef)
} }
fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer) fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer)

View File

@@ -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);
} }

View File

@@ -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();
}
} }

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/requests/payment/quote.dart';
@@ -20,7 +18,6 @@ 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/flow.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/recipient/pmethods.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'; import 'package:pshared/utils/currency.dart';
@@ -36,12 +33,10 @@ class QuotationProvider extends ChangeNotifier {
PaymentAmountProvider payment, PaymentAmountProvider payment,
WalletsProvider wallets, WalletsProvider wallets,
PaymentFlowProvider flow, PaymentFlowProvider flow,
PaymentMethodsProvider methods,
) { ) {
_organizations = venue; _organizations = venue;
final t = flow.selectedType; final destination = flow.selectedPaymentData;
final method = methods.methods.firstWhereOrNull((m) => m.type == t); if ((wallets.selectedWallet != null) && (destination != null)) {
if ((wallets.selectedWallet != null) && (method != null)) {
getQuotation(PaymentIntent( getQuotation(PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
amount: Money( amount: Money(
@@ -49,7 +44,7 @@ class QuotationProvider extends ChangeNotifier {
// TODO: adapt to possible other sources // TODO: adapt to possible other sources
currency: currencyCodeToString(wallets.selectedWallet!.currency), currency: currencyCodeToString(wallets.selectedWallet!.currency),
), ),
destination: method.data, destination: destination,
source: ManagedWalletPaymentMethod( source: ManagedWalletPaymentMethod(
managedWalletRef: wallets.selectedWallet!.id, managedWalletRef: wallets.selectedWallet!.id,
), ),

View File

@@ -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);
} }

View File

@@ -15,6 +15,8 @@ import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.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';
@@ -23,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:pshared/provider/payment/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:pshared/service/payment/wallets.dart';
import 'package:pweb/providers/account.dart'; import 'package:pweb/providers/account.dart';

View File

@@ -7,7 +7,6 @@ import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.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:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart'; import 'package:pweb/pages/dashboard/payouts/form.dart';
@@ -21,9 +20,9 @@ class PaymentFromWrappingWidget extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(), create: (_) => PaymentAmountProvider(),
), ),
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>( ChangeNotifierProxyProvider4<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, QuotationProvider>(
create: (_) => QuotationProvider(), create: (_) => QuotationProvider(),
update: (context, orgnization, payment, wallet, flow, methods, provider) => provider!..update(orgnization, payment, wallet, flow, methods), update: (context, orgnization, payment, wallet, flow, provider) => provider!..update(orgnization, payment, wallet, flow),
), ),
], ],
child: const PaymentFormWidget(), child: const PaymentFormWidget(),

View File

@@ -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());
}
} }
} }

View File

@@ -106,12 +106,21 @@ class _CryptoAddressFormState extends State<CryptoAddressForm> {
} }
if (newData != null && newData != oldData) { if (newData != null && newData != oldData) {
_addressCtrl.text = newData.address; final hasAddressChange = newData.address != _addressCtrl.text;
_tokenCtrl.text = newData.asset?.tokenSymbol ?? ''; final hasTokenChange = newData.asset?.tokenSymbol != _tokenCtrl.text;
_contractCtrl.text = newData.asset?.contractAddress ?? ''; final hasContractChange = newData.asset?.contractAddress != _contractCtrl.text;
_memoCtrl.text = newData.memo ?? ''; final hasMemoChange = newData.memo != _memoCtrl.text;
_chain = newData.asset?.chain ?? ChainNetwork.unspecified; final hasChainChange = newData.asset?.chain != _chain;
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
if (hasAddressChange) _addressCtrl.text = newData.address;
if (hasTokenChange) _tokenCtrl.text = newData.asset?.tokenSymbol ?? '';
if (hasContractChange) _contractCtrl.text = newData.asset?.contractAddress ?? '';
if (hasMemoChange) _memoCtrl.text = newData.memo ?? '';
if (hasChainChange) _chain = newData.asset?.chain ?? ChainNetwork.unspecified;
if (hasAddressChange || hasTokenChange || hasContractChange || hasMemoChange || hasChainChange) {
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
}
} }
} }

View File

@@ -69,11 +69,19 @@ class _IbanFormState extends State<IbanForm> {
} }
if (newData != null && newData != oldData) { if (newData != null && newData != oldData) {
_ibanController.text = newData.iban; final hasIbanChange = newData.iban != _ibanController.text;
_accountHolderController.text = newData.accountHolder; final hasHolderChange = newData.accountHolder != _accountHolderController.text;
_bicController.text = newData.bic ?? ''; final hasBicChange = (newData.bic ?? '') != _bicController.text;
_bankNameController.text = newData.bankName ?? ''; final hasBankNameChange = (newData.bankName ?? '') != _bankNameController.text;
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
if (hasIbanChange) _ibanController.text = newData.iban;
if (hasHolderChange) _accountHolderController.text = newData.accountHolder;
if (hasBicChange) _bicController.text = newData.bic ?? '';
if (hasBankNameChange) _bankNameController.text = newData.bankName ?? '';
if (hasIbanChange || hasHolderChange || hasBicChange || hasBankNameChange) {
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
}
} }
} }

View File

@@ -82,14 +82,31 @@ class _RussianBankFormState extends State<RussianBankForm> {
} }
if (newData != null && newData != oldData) { if (newData != null && newData != oldData) {
_recipientNameController.text = newData.recipientName; final hasRecipientNameChange = newData.recipientName != _recipientNameController.text;
_innController.text = newData.inn; final hasInnChange = newData.inn != _innController.text;
_kppController.text = newData.kpp; final hasKppChange = newData.kpp != _kppController.text;
_bankNameController.text = newData.bankName; final hasBankNameChange = newData.bankName != _bankNameController.text;
_bikController.text = newData.bik; final hasBikChange = newData.bik != _bikController.text;
_accountNumberController.text = newData.accountNumber; final hasAccountNumberChange = newData.accountNumber != _accountNumberController.text;
_correspondentAccountController.text = newData.correspondentAccount; final hasCorrespondentAccountChange = newData.correspondentAccount != _correspondentAccountController.text;
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
if (hasRecipientNameChange) _recipientNameController.text = newData.recipientName;
if (hasInnChange) _innController.text = newData.inn;
if (hasKppChange) _kppController.text = newData.kpp;
if (hasBankNameChange) _bankNameController.text = newData.bankName;
if (hasBikChange) _bikController.text = newData.bik;
if (hasAccountNumberChange) _accountNumberController.text = newData.accountNumber;
if (hasCorrespondentAccountChange) _correspondentAccountController.text = newData.correspondentAccount;
if (hasRecipientNameChange ||
hasInnChange ||
hasKppChange ||
hasBankNameChange ||
hasBikChange ||
hasAccountNumberChange ||
hasCorrespondentAccountChange) {
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
}
} }
} }

View File

@@ -51,7 +51,9 @@ class _WalletFormState extends State<WalletForm> {
} }
if (newData != null && newData != oldData) { if (newData != null && newData != oldData) {
_walletIdController.text = newData.walletId; if (newData.walletId != _walletIdController.text) {
_walletIdController.text = newData.walletId;
}
} }
} }

View File

@@ -4,17 +4,17 @@ import 'package:collection/collection.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.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/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/posthog.dart';
@@ -38,16 +38,12 @@ class PaymentPage extends StatefulWidget {
class _PaymentPageState extends State<PaymentPage> { class _PaymentPageState extends State<PaymentPage> {
late final TextEditingController _searchController; late final TextEditingController _searchController;
late final FocusNode _searchFocusNode; late final FocusNode _searchFocusNode;
late final PaymentFlowProvider _flowProvider;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController = TextEditingController(); _searchController = TextEditingController();
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
_flowProvider = PaymentFlowProvider(
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
} }
@@ -56,20 +52,12 @@ class _PaymentPageState extends State<PaymentPage> {
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_searchFocusNode.dispose(); _searchFocusNode.dispose();
_flowProvider.dispose();
super.dispose(); super.dispose();
} }
void _initializePaymentPage() { void _initializePaymentPage() {
final methodsProvider = context.read<PaymentMethodsProvider>(); final methodsProvider = context.read<PaymentMethodsProvider>();
_handleWalletAutoSelection(methodsProvider); _handleWalletAutoSelection(methodsProvider);
final recipient = context.read<RecipientsProvider>().currentObject;
_syncFlowProvider(
recipient: recipient,
methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType,
);
} }
void _handleSearchChanged(String query) { void _handleSearchChanged(String query) {
@@ -79,11 +67,12 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleRecipientSelected(Recipient recipient) { void _handleRecipientSelected(Recipient recipient) {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>(); final methodsProvider = context.read<PaymentMethodsProvider>();
final flowProvider = context.read<PaymentFlowProvider>();
recipientProvider.setCurrentObject(recipient.id); recipientProvider.setCurrentObject(recipient.id);
_flowProvider.reset( flowProvider.reset(
recipient: recipient, recipient: recipient,
availableTypes: _availablePaymentTypes(recipient, methodsProvider), methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType, preferredType: widget.initialPaymentType,
); );
_clearSearchField(); _clearSearchField();
@@ -92,11 +81,12 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleRecipientCleared() { void _handleRecipientCleared() {
final recipientProvider = context.read<RecipientsProvider>(); final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>(); final methodsProvider = context.read<PaymentMethodsProvider>();
final flowProvider = context.read<PaymentFlowProvider>();
recipientProvider.setCurrentObject(null); recipientProvider.setCurrentObject(null);
_flowProvider.reset( flowProvider.reset(
recipient: null, recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider), methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType, preferredType: widget.initialPaymentType,
); );
_clearSearchField(); _clearSearchField();
@@ -110,7 +100,7 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleSendPayment() { void _handleSendPayment() {
// TODO: Handle Payment logic // TODO: Handle Payment logic
PosthogService.paymentInitiated(method: _flowProvider.selectedType); PosthogService.paymentInitiated(method: context.read<PaymentFlowProvider>().selectedType);
} }
@override @override
@@ -118,23 +108,29 @@ class _PaymentPageState extends State<PaymentPage> {
final methodsProvider = context.watch<PaymentMethodsProvider>(); final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>(); final recipientProvider = context.watch<RecipientsProvider>();
final recipient = recipientProvider.currentObject; final recipient = recipientProvider.currentObject;
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
_syncFlowProvider( return ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
recipient: recipient, create: (_) => PaymentFlowProvider(
methodsProvider: methodsProvider, initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
preferredType: recipient != null ? widget.initialPaymentType : null, ),
); update: (_, recipients, methods, flow) {
final provider = flow ?? PaymentFlowProvider(
return ChangeNotifierProvider.value( initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
value: _flowProvider, );
final currentRecipient = recipients.currentObject;
provider.syncWith(
recipient: currentRecipient,
methodsProvider: methods,
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
);
return provider;
},
child: PaymentPageBody( child: PaymentPageBody(
onBack: widget.onBack, onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination, fallbackDestination: widget.fallbackDestination,
recipient: recipient, recipient: recipient,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
methodsProvider: methodsProvider, methodsProvider: methodsProvider,
availablePaymentTypes: availableTypes,
searchController: _searchController, searchController: _searchController,
searchFocusNode: _searchFocusNode, searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged, onSearchChanged: _handleSearchChanged,
@@ -155,33 +151,6 @@ class _PaymentPageState extends State<PaymentPage> {
} }
} }
void _syncFlowProvider({
required Recipient? recipient,
required PaymentMethodsProvider methodsProvider,
PaymentType? preferredType,
}) {
_flowProvider.sync(
recipient: recipient,
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
preferredType: preferredType,
);
}
MethodMap _availablePaymentTypes(
Recipient? recipient,
PaymentMethodsProvider methodsProvider,
) {
if (recipient == null || !methodsProvider.isReady) return {};
final methodsForRecipient = methodsProvider.methods.where(
(method) => !method.isArchived && method.recipientRef == recipient.id,
);
return {
for (final method in methodsForRecipient) method.type: method.data,
};
}
PaymentMethod? _getPaymentMethodForWallet( PaymentMethod? _getPaymentMethodForWallet(
Wallet wallet, Wallet wallet,
PaymentMethodsProvider methodsProvider, PaymentMethodsProvider methodsProvider,

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -17,7 +16,6 @@ class PaymentPageBody extends StatelessWidget {
final Recipient? recipient; final Recipient? recipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider; final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -32,7 +30,6 @@ class PaymentPageBody extends StatelessWidget {
required this.recipient, required this.recipient,
required this.recipientProvider, required this.recipientProvider,
required this.methodsProvider, required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -61,7 +58,6 @@ class PaymentPageBody extends StatelessWidget {
recipient: recipient, recipient: recipient,
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
methodsProvider: methodsProvider, methodsProvider: methodsProvider,
availablePaymentTypes: availablePaymentTypes,
fallbackDestination: fallbackDestination, fallbackDestination: fallbackDestination,
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,

View File

@@ -1,12 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart'; import 'package:pweb/pages/payment_methods/payment_page/header.dart';
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
final Recipient? recipient; final Recipient? recipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider; final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
required this.recipient, required this.recipient,
required this.recipientProvider, required this.recipientProvider,
required this.methodsProvider, required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Align( return Align(
@@ -98,12 +91,7 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared, onRecipientCleared: onRecipientCleared,
), ),
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection( PaymentInfoSection(dimensions: dimensions),
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(), const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge), SizedBox(height: dimensions.paddingXXXLarge),

View File

@@ -1,12 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/dashboard/payouts/widget.dart'; import 'package:pweb/pages/dashboard/payouts/widget.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
@@ -27,7 +23,6 @@ class PaymentPageContent extends StatelessWidget {
final Recipient? recipient; final Recipient? recipient;
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider; final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -42,7 +37,6 @@ class PaymentPageContent extends StatelessWidget {
required this.recipient, required this.recipient,
required this.recipientProvider, required this.recipientProvider,
required this.methodsProvider, required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -55,7 +49,6 @@ class PaymentPageContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Align( return Align(
@@ -98,12 +91,7 @@ class PaymentPageContent extends StatelessWidget {
onRecipientCleared: onRecipientCleared, onRecipientCleared: onRecipientCleared,
), ),
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection( PaymentInfoSection(dimensions: dimensions),
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
const PaymentFromWrappingWidget(), const PaymentFromWrappingWidget(),
SizedBox(height: dimensions.paddingXXXLarge), SizedBox(height: dimensions.paddingXXXLarge),

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payment_methods/form.dart'; import 'package:pweb/pages/payment_methods/form.dart';
@@ -15,25 +14,18 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentInfoSection extends StatelessWidget { class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions; final AppDimensions dimensions;
final MethodMap availableTypes;
final PaymentFlowProvider flowProvider;
final Recipient? recipient;
const PaymentInfoSection({ const PaymentInfoSection({
super.key, super.key,
required this.dimensions, required this.dimensions,
required this.availableTypes,
required this.flowProvider,
required this.recipient,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final hasRecipient = recipient != null; final flowProvider = context.watch<PaymentFlowProvider>();
final MethodMap resolvedAvailableTypes = hasRecipient final hasRecipient = flowProvider.hasRecipient;
? availableTypes final MethodMap resolvedAvailableTypes = flowProvider.availableTypes;
: {for (final type in PaymentType.values) type: null};
if (hasRecipient && resolvedAvailableTypes.isEmpty) { if (hasRecipient && resolvedAvailableTypes.isEmpty) {
return Text(loc.recipientNoPaymentDetails); return Text(loc.recipientNoPaymentDetails);
@@ -62,7 +54,7 @@ class PaymentInfoSection extends StatelessWidget {
flowProvider.setManualPaymentData(data); flowProvider.setManualPaymentData(data);
} }
}, },
initialData: hasRecipient ? resolvedAvailableTypes[selectedType] : flowProvider.manualPaymentData, initialData: flowProvider.selectedPaymentData,
isEditable: !hasRecipient, isEditable: !hasRecipient,
), ),
], ],

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payout_page/methods/widget.dart'; import 'package:pweb/pages/payout_page/methods/widget.dart';
import 'package:pweb/pages/payout_page/wallet/wigets.dart'; import 'package:pweb/pages/payout_page/wallet/wigets.dart';

View File

@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -3,9 +3,10 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
class WalletEditFields extends StatelessWidget { class WalletEditFields extends StatelessWidget {
const WalletEditFields({super.key}); const WalletEditFields({super.key});

View File

@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields.dart'; import 'package:pweb/pages/payout_page/wallet/edit/fields.dart';
import 'package:pweb/pages/payout_page/wallet/edit/header.dart'; import 'package:pweb/pages/payout_page/wallet/edit/header.dart';
import 'package:pweb/pages/payout_page/wallet/history/history.dart'; import 'package:pweb/pages/payout_page/wallet/history/history.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payout_page/wallet/history/filters.dart'; import 'package:pweb/pages/payout_page/wallet/history/filters.dart';
import 'package:pweb/pages/payout_page/wallet/history/table.dart'; import 'package:pweb/pages/payout_page/wallet/history/table.dart';
import 'package:pweb/providers/wallet_transactions.dart'; import 'package:pweb/providers/wallet_transactions.dart';

View File

@@ -3,9 +3,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payout_page/wallet/card.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payout_page/wallet/card.dart';
class WalletWidgets extends StatelessWidget { class WalletWidgets extends StatelessWidget {
final void Function(Wallet) onWalletTap; final void Function(Wallet) onWalletTap;

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/l10n/chain.dart'; import 'package:pshared/utils/l10n/chain.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/wallet_top_up/details.dart'; import 'package:pweb/pages/wallet_top_up/details.dart';
import 'package:pweb/pages/wallet_top_up/header.dart'; import 'package:pweb/pages/wallet_top_up/header.dart';
import 'package:pweb/pages/wallet_top_up/meta.dart'; import 'package:pweb/pages/wallet_top_up/meta.dart';

View File

@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pweb/pages/wallet_top_up/content.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/wallet_top_up/content.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';