Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions controllers/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ func setup() error {
for i := 0; i < 10; i++ {
// Skip sleep in test mode to avoid timeout
// time.Sleep(time.Duration(time.Duration(rand.Intn(10)) * time.Second))
_, err := test.CreateTestPaymentOrder(nil, map[string]interface{}{
// Reuse testCtx.token so CreateERC20Token is not called repeatedly (would insert
// duplicate TST rows and break CreateTestSenderProfile's Symbol-only lookup).
_, err := test.CreateTestPaymentOrder(testCtx.token, map[string]interface{}{
"gateway_id": uuid.New().String(),
"provider": providerProfile,
})
Expand Down Expand Up @@ -2112,7 +2114,9 @@ func TestProvider(t *testing.T) {
WithTransactions().
Only(context.Background())
assert.NoError(t, err)
assert.Equal(t, paymentorder.StatusSettling, updatedOrder.Status)
// Settlement tx fails in tests; compensating path deletes the settling log and
// reverts the order to fulfilling so the provider can retry.
assert.Equal(t, paymentorder.StatusFulfilling, updatedOrder.Status)

fulfillingLogExists, err := db.Client.TransactionLog.Query().
Where(
Expand Down
32 changes: 16 additions & 16 deletions controllers/sender/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,13 @@ func (ctrl *SenderController) InitiatePaymentOrder(ctx *gin.Context) {
rateValidationResult := rateResult.rateResult
achievableRate := rateValidationResult.Rate

// Validate that the provided rate is achievable
// Allow for a small tolerance (0.1%) to account for minor rate fluctuations
// Validate provided rate against market rate with same symmetric 0.1% band as blockchain indexer
// (validateAndPreparePaymentOrderData: abs(event.Rate - validated) <= tolerance).
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% tolerance
if payload.Rate.LessThan(achievableRate.Sub(tolerance)) {
if payload.Rate.Sub(achievableRate).Abs().GreaterThan(tolerance) {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{
Field: "Rate",
Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", payload.Rate, achievableRate),
Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", payload.Rate, achievableRate),
})
return
}
Expand Down Expand Up @@ -946,11 +946,11 @@ func (ctrl *SenderController) initiateOfframpOrderV2(ctx *gin.Context, payload t
}
rateValidationResult = rateResult
achievableRate := rateValidationResult.Rate
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001))
if providedRate.LessThan(achievableRate.Sub(tolerance)) {
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer
if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{
Field: "Rate",
Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate),
Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate),
})
return
}
Expand Down Expand Up @@ -1015,11 +1015,11 @@ func (ctrl *SenderController) initiateOfframpOrderV2(ctx *gin.Context, payload t
}
rateValidationResult = rateResult
achievableRate := rateValidationResult.Rate
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% tolerance
if providedRate.LessThan(achievableRate.Sub(tolerance)) {
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer
if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{
Field: "Rate",
Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate),
Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate),
})
return
}
Expand Down Expand Up @@ -1594,11 +1594,11 @@ func (ctrl *SenderController) initiateOnrampOrderV2(ctx *gin.Context, payload ty
}
rateValidationResult = rateResult
achievableRate := rateValidationResult.Rate
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001))
if providedRate.GreaterThan(achievableRate.Add(tolerance)) {
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer
if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{
Field: "Rate",
Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate),
Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate),
})
return
}
Expand Down Expand Up @@ -1662,11 +1662,11 @@ func (ctrl *SenderController) initiateOnrampOrderV2(ctx *gin.Context, payload ty
}
rateValidationResult = rateResult
achievableRate := rateValidationResult.Rate
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001))
if providedRate.GreaterThan(achievableRate.Add(tolerance)) {
tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer
if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{
Field: "Rate",
Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate),
Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate),
})
return
}
Expand Down
28 changes: 14 additions & 14 deletions controllers/sender/sender_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func setup() error {
SetDecimals(6).
SetNetworkID(networkId).
SetIsEnabled(true).
SetBaseCurrency("NGN"). // Set to NGN to avoid Redis dependency
SetBaseCurrency("NGN"). // Direct NGN/NGN match: ValidateRate normalizes to 1; payloads must use rate 1 (symmetric tolerance vs queue 750)
OnConflict().
UpdateNewValues().
ID(context.Background())
Expand Down Expand Up @@ -120,7 +120,7 @@ func setup() error {
return fmt.Errorf("CreateTestProviderProfile.sender_test: %w", err)
}

// Create ProviderOrderToken for rate validation (two-sided rates; match test payload rate 750)
// Create ProviderOrderToken for rate validation (Redis queue uses 750; NGN/NGN direct match normalizes ValidateRate to 1, matching payload rate "1")
providerOrderToken, err := test.AddProviderOrderTokenToProvider(map[string]interface{}{
"provider": providerProfile,
"token_id": int(tokenId),
Expand All @@ -139,7 +139,7 @@ func setup() error {
}

// Create ProvisionBucket for bucket-based rate validation
// Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat
// Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1
bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{
"provider_id": providerProfile.ID,
"currency_id": currency.ID,
Expand Down Expand Up @@ -418,7 +418,7 @@ func TestSender(t *testing.T) {
validPayload := map[string]interface{}{
"amount": "100",
"token": testCtx.nativeTokenSymbol,
"rate": "750",
"rate": "1",
"network": testCtx.nativeNetworkIdentifier,
"recipient": map[string]interface{}{
"institution": "MOMONGPC",
Expand Down Expand Up @@ -492,7 +492,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "0",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": network.Identifier,
"recipient": map[string]interface{}{
"institution": "MOMONGPC",
Expand Down Expand Up @@ -528,7 +528,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "-100",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": network.Identifier,
"recipient": map[string]interface{}{
"institution": "MOMONGPC",
Expand Down Expand Up @@ -644,7 +644,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "100",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": network.Identifier,
"recipient": map[string]interface{}{
"institution": "MOMONGPC",
Expand Down Expand Up @@ -795,7 +795,7 @@ func TestSender(t *testing.T) {
assert.NotNil(t, providerOrderToken, "Provider order token should be created")

// Create ProvisionBucket for bucket-based rate validation
// Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat
// Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1
bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{
"provider_id": providerProfile.ID,
"currency_id": currency.ID,
Expand Down Expand Up @@ -872,7 +872,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "100",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": testNetwork.Identifier,
"reference": fmt.Sprintf("maxfeecap_test_%d", time.Now().UnixNano()),
"recipient": map[string]interface{}{
Expand Down Expand Up @@ -982,7 +982,7 @@ func TestSender(t *testing.T) {
assert.NotNil(t, providerOrderToken, "Provider order token should be created")

// Create ProvisionBucket for bucket-based rate validation
// Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat
// Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1
bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{
"provider_id": providerProfile.ID,
"currency_id": currency.ID,
Expand Down Expand Up @@ -1058,7 +1058,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "100",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": testNetwork.Identifier,
"reference": fmt.Sprintf("maxfeecap_below_%d", time.Now().UnixNano()),
"recipient": map[string]interface{}{
Expand Down Expand Up @@ -1154,7 +1154,7 @@ func TestSender(t *testing.T) {
"currency_id": currency.ID,
"network": testCtx.networkIdentifier,
"conversion_rate_type": "fixed",
"fixed_conversion_rate": decimal.NewFromFloat(750.0), // Match test payload rate
"fixed_conversion_rate": decimal.NewFromFloat(750.0), // Provider DB rate; ValidateRate still returns 1 for NGN/NGN direct match vs payload "1"
"max_order_amount": decimal.NewFromFloat(10000),
"min_order_amount": decimal.NewFromFloat(1),
"max_order_amount_otc": decimal.Zero,
Expand All @@ -1168,7 +1168,7 @@ func TestSender(t *testing.T) {
assert.NotNil(t, providerOrderToken, "Provider order token should be created")

// Create ProvisionBucket for bucket-based rate validation
// Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat
// Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1
bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{
"provider_id": providerProfile.ID,
"currency_id": currency.ID,
Expand Down Expand Up @@ -1237,7 +1237,7 @@ func TestSender(t *testing.T) {
payload := map[string]interface{}{
"amount": "100",
"token": testCtx.token.Symbol,
"rate": "750",
"rate": "1",
"network": testNetwork.Identifier,
"reference": fmt.Sprintf("nomaxfeecap_%d", time.Now().UnixNano()),
"recipient": map[string]interface{}{
Expand Down
59 changes: 52 additions & 7 deletions services/common/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,48 @@ func HandleCancellation(ctx context.Context, createdPaymentOrder *ent.PaymentOrd
return nil
}

// handleCancellationForIndexedOrder updates the existing payment order for this gateway when one exists
// (e.g. API order linked in ProcessPaymentOrderFromBlockchain before validation). Calling
// HandleCancellation(ctx, nil, paymentOrderFields, ...) creates a second row with the same gateway_id
// while the original can remain pending — duplicate orders.
func handleCancellationForIndexedOrder(
ctx context.Context,
network *ent.Network,
gatewayID string,
existingOrder *ent.PaymentOrder,
paymentOrderFields *types.PaymentOrderFields,
reason string,
refundOrder func(context.Context, *ent.Network, string) error,
) error {
normGW := normalizeGatewayID(gatewayID)
var orderToCancel *ent.PaymentOrder
if existingOrder != nil && normalizeGatewayID(existingOrder.GatewayID) == normGW {
orderToCancel = existingOrder
} else {
foundOrder, err := db.Client.PaymentOrder.
Query().
Where(
paymentorder.GatewayIDEQ(normGW),
paymentorder.HasTokenWith(
tokenent.HasNetworkWith(
networkent.IdentifierEQ(network.Identifier),
),
),
).
Only(ctx)
if err != nil && !ent.IsNotFound(err) {
return fmt.Errorf("%s - failed to query order for cancellation: %w", normGW, err)
}
if foundOrder != nil {
orderToCancel = foundOrder
}
}
if orderToCancel != nil {
return HandleCancellation(ctx, orderToCancel, nil, reason, refundOrder)
}
return HandleCancellation(ctx, nil, paymentOrderFields, reason, refundOrder)
}

// CheckAMLCompliance checks if a transaction is compliant with AML regulations.
func CheckAMLCompliance(rpcUrl string, txHash string) (bool, error) {
if !strings.Contains(rpcUrl, "shield3") {
Expand Down Expand Up @@ -1464,16 +1506,17 @@ func validateAndPreparePaymentOrderData(
utils.RateSideSell, // This is for offramp orders
)

if rateResult.Rate == decimal.NewFromInt(1) && paymentOrderFields.Rate != decimal.NewFromInt(1) {
err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder)
// Must check rateErr before using rateResult (error path may leave result zero-valued).
if rateErr != nil {
err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
}
return nil, nil, nil, nil, nil, nil
}

if rateErr != nil {
err := HandleCancellation(ctx, nil, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder)
if rateResult.Rate == decimal.NewFromInt(1) && paymentOrderFields.Rate != decimal.NewFromInt(1) {
err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
}
Expand All @@ -1485,7 +1528,7 @@ func validateAndPreparePaymentOrderData(
rateDiff := event.Rate.Sub(rateResult.Rate).Abs()

if rateDiff.GreaterThan(tolerance) {
err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder)
err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
}
Expand Down Expand Up @@ -1531,7 +1574,9 @@ func validateAndPreparePaymentOrderData(
// 3. Provider does not support the network
// 4. Provider does not support the currency
// 5. Provider have not configured a settlement address for the network
_ = HandleCancellation(ctx, nil, paymentOrderFields, "Provider not available", refundOrder)
if cErr := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Provider not available", refundOrder); cErr != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("%s - failed to handle cancellation: %w", paymentOrderFields.GatewayID, cErr)
}
return nil, nil, nil, nil, nil, nil
} else {
return nil, nil, nil, nil, nil, fmt.Errorf("%s - failed to fetch provider: %w", paymentOrderFields.GatewayID, err)
Expand All @@ -1545,7 +1590,7 @@ func validateAndPreparePaymentOrderData(
}

if provisionBucket == nil && !isPrivate {
err := HandleCancellation(ctx, nil, paymentOrderFields, "Amount is larger than the maximum bucket", refundOrder)
err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Amount is larger than the maximum bucket", refundOrder)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
}
Expand Down
Loading
Loading