Gas topup limits

This commit is contained in:
Stephan D
2025-12-25 12:26:24 +01:00
parent f02f3449f3
commit 31d93e5113
16 changed files with 906 additions and 109 deletions

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

View File

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