---
title: Payment Safety Edge Cases
tags: [testing, payment, safety, aml, scanner, edge-cases]
created: 2026-06-08
---

# Payment Safety Edge Cases

Automated tests live in `backend/__tests__/payment-edge-cases.test.ts` (38 tests, all passing).
This document records the design rationale, current system behaviour, and remaining gaps for each
of the five payment edge-case families.

## Edge Case Families

### 1 · Blacklisted / OFAC-Sanctioned Sender Wallet

**How the system works**
The OFAC SDN list is downloaded from US Treasury once per 24 h and cached locally. Address
screening runs when the seller has opted in (`requireAmlCheck=true` on the seller offer). For
on-chain (BSC Verifier) payments the buyer address comes from the ERC-20 Transfer log `from` field.
For direct-balance (AMN Scanner) payments the buyer address must be stored in
`amnScannerDirectBalance.buyerAddress` at intent-creation time.

**Implemented behaviour**

| Scenario | Result |
|---|---|
| OFAC-listed address + seller opted in | `block=true`, `reason=aml_sanctions` |
| OFAC-listed address + seller NOT opted in | `block=false`, `reason=aml_not_required` |
| OFAC provider unreachable + `amlBlockOnFailure=true` | `block=true` (fail-closed) |
| OFAC provider unreachable + `amlBlockOnFailure=false` | `block=false`, `providerUnavailable=true` (fail-open) |
| No buyer address (direct-balance, no tx hash) | `block=false`, `reason=no_buyer_address_to_screen` |
| Direct-balance + `buyerAddress` stored + sanctioned | `block=true` via `fundDirectBalancePayment` AML gate |
| BTC / XMR addresses in SDN XML | Ignored (only EVM `0x…` addresses parsed) |

**Remaining gaps**

- AML is opt-in per seller. A sanctioned address can pay any seller with `requireAmlCheck=false`.
  Platform-level mandatory screening could be added via a `PLATFORM_AML_REQUIRED=1` env flag.
- SDN list can be up to 24 h stale.

**Relevant code**
- `src/services/payment/safety/ofacProvider.ts` — SDN download, parse, cache
- `src/services/payment/safety/amlScreeningService.ts` — `screenPaymentForAml()`
- `src/services/payment/amnScanner/directBalancePaymentService.ts` — `fundDirectBalancePayment()` AML gate

---

### 2 · Overpayment and Underpayment

**How the system works**
On-chain (BSC Verifier): amount ≥ expected → success; amount < expected → `insufficient_amount`.
Direct-balance (webhook): delta ≥ expected → `funded=true`; delta < expected → `funded=false`.
Overpay excess is accepted silently and remains locked at the derived destination address.

**Implemented behaviour**

| Scenario | Result |
|---|---|
| On-chain overpay (15 USDT vs 10 expected) | `success=true`, `actualAmount` returned |
| On-chain exact match | `success=true` |
| On-chain underpay by 1 wei | `failureReason=insufficient_amount`, both amounts returned |
| Direct-balance overpay | `funded=true`, `reason=paid` |
| Direct-balance exact match | `funded=true` |
| Direct-balance underpay | `funded=false`, `reason=underpaid:delta=…,expected=…` **+ `payment-underpaid` socket event** with `shortfall` |
| Direct-balance zero balance | `funded=false` |

**Remaining gaps**

- No dedicated `underpaid` payment status in the DB — payment stays `pending` until expiry.
- Overpay excess locked at derived address with no automated recovery.

**Relevant code**
- `src/services/payment/decentralizedPaymentService.ts` — `BSCTransactionVerifier.verifyTransfer()`
- `src/services/payment/amnScanner/directBalancePaymentService.ts` — `processDirectBalanceWebhook()`

---

### 3 · Native Coin (ETH / BNB) Sent Instead of ERC-20

**How the system works**
Native coin transfers emit no ERC-20 Transfer log. On-chain verification sees no Transfer events.
The AMN scanner watches a specific ERC-20 token balance; native coin does not affect it.

**Implemented behaviour**

| Scenario | Result |
|---|---|
| On-chain tx succeeded, empty logs | `failureReason=wrong_asset` ← **distinguishable from transfer_not_found** |
| On-chain tx with only Approval log | `failureReason=transfer_not_found` |
| Direct-balance webhook, ERC-20 balance unchanged | `funded=false` — scanner unaware of native coin arrival |
| Direct-balance API check, native baseline captured | `warning=native_coin_detected:delta=N` when native balance increased |

**Remaining gaps**

- The direct-balance **webhook** path cannot detect native coin because the scanner only reports
  ERC-20 balance changes. A native-coin watch could be added to the scanner service separately.
- Native coin locked at derived address — no automated sweep path.

**`wrong_asset` vs `transfer_not_found`**

| failureReason | Meaning |
|---|---|
| `wrong_asset` | Tx succeeded on-chain with zero logs — almost certainly native coin was sent |
| `transfer_not_found` | Tx succeeded but logs exist yet none match token/recipient — likely wrong token or misconfigured tx |

**Relevant code**
- `decentralizedPaymentService.ts:verifyTransfer()` — `receipt.logs.length === 0` → `wrong_asset`
- `directBalancePaymentService.ts:createDirectBalancePayIntent()` — captures `nativeBaselineBalance`
- `directBalancePaymentService.ts:checkDirectBalancePayment()` — compares native balance to baseline

---

### 4 · Wrong ERC-20 Token Sent to Deposit Address

**How the system works**
On-chain: BSC Verifier detects token/recipient mismatches explicitly. Direct-balance webhook: the
scanner reports the token address in the payload; mismatches are caught at intake.

**Implemented behaviour**

| Scenario | Result |
|---|---|
| On-chain: USDC sent when USDT expected | `failureReason=wrong_token`, `actualToken` populated |
| On-chain: right token, wrong recipient | `failureReason=wrong_recipient`, `actualRecipient` populated |
| On-chain: completely random token + address | `failureReason=transfer_not_found` |
| Direct-balance webhook: wrong `tokenAddress` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
| Direct-balance webhook: wrong `chainId` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
| Direct-balance webhook: wrong `address` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |

**Remaining gaps**

- Wrong-token funds are locked at the derived address — no automated recovery.

**Relevant code**
- `decentralizedPaymentService.ts` — `parseTransferLogs()`, `verifyTransfer()` wrong-token detection
- `directBalancePaymentService.ts:processDirectBalanceWebhook()` — mismatch emits `payment-wrong-token`

---

### 5 · Smart-Contract Sender

**How the system works**
The ERC-20 Transfer log `from` field (topics[1]) is the immediate sender. Smart contracts
(DEX routers, multisigs, mixers) appear here. The system has no built-in EOA detection — only
OFAC-listed contract addresses are blocked.

**Implemented behaviour**

| Scenario | Result |
|---|---|
| Contract address in Transfer log | Surfaced as `evidence.from`, passed to AML |
| OFAC-listed contract + seller opted in | `block=true` |
| Unlisted contract (e.g. mixer) + not on OFAC | `block=false` — **gap** |
| Gnosis Safe (legitimate multisig) | `block=false` — indistinguishable from mixer by address alone |
| `requireEoaSender=true` + contract bytecode | `failureReason=contract_sender`, `success=false` |
| `requireEoaSender=true` + EOA (empty bytecode) | `success=true` as normal |
| `requireEoaSender` not set (default) | Contract sender passes — backward-compatible |

**Enabling EOA enforcement**

Set env flag at any scope (per-service or globally):

```
TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1
```

This is wired to all 5 `verifyTransfer` call sites:
- `transactionSafetyProvider.ts` (webhook safety evaluation)
- `paymentController.ts` (new + re-verify web3 payment paths)
- `paymentRoutes.ts`
- `marketplace/routes.ts`

**Remaining gaps**

- No bytecode check in `screenPaymentForAml()` itself — the AML layer cannot gate on sender type.
- No seller-level `requireEoaSender` flag — currently only a platform-wide env toggle.
- Gnosis Safe and unlisted mixers remain indistinguishable at address level. A known-multisig
  factory allowlist would be required for principled permitting.

**Relevant code**
- `decentralizedPaymentService.ts:verifyTransfer()` — `requireEoaSender` → `eth_getCode` → `contract_sender`
- `transactionSafetyProvider.ts` — reads `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER`

---

## Test File Reference

`backend/__tests__/payment-edge-cases.test.ts`

| Section | Tests | Status |
|---|---|---|
| 1 · Blacklisted / OFAC wallet | 10 | ✓ all pass |
| 2 · Overpayment and underpayment | 8 | ✓ all pass |
| 3 · Native coin (ETH/BNB) | 4 | ✓ all pass |
| 4 · Wrong ERC-20 token | 7 | ✓ all pass |
| 5 · Smart-contract sender | 9 | ✓ all pass |
| **Total** | **38** | **✓** |

Tests labelled `GAP ·` document known system limitations that pass because they describe current
(undesired) behaviour. Tests labelled `FIXED ·` confirm a gap was mitigated.

Run:
```bash
cd backend && node_modules/.bin/jest __tests__/payment-edge-cases.test.ts --no-coverage
```
