docs: sync from backend 3a50dc4 - promote postgres integration

This commit is contained in:
Siavash Sameni
2026-05-31 14:20:11 +04:00
parent 622dbe4dcb
commit 773f5db454
7 changed files with 329 additions and 13 deletions

View File

@@ -0,0 +1,180 @@
---
title: Oracle Pricing & Stablecoin Depeg Protection
status: implemented on backend integrate-main-into-development
owner: backend
created: 2026-05-31
branch: backend integrate-main-into-development at 3a50dc4
storage: Postgres `payment_quotes` plus Mongo `Payment.quote` mirror during dual-write
---
# Oracle Pricing & Stablecoin Depeg Protection
## 1. Goal
Let sellers price in **any supported currency** (USD, EUR, **IRR**, **TRY**, … plus the stablecoins themselves), let the buyer settle in their chosen **stablecoin + chain** (USDC/USDT on the allow-listed chains), and compute the **on-chain amount server-side from a live price quote** so that:
- **Primary: depeg protection.** An invoice is a *value obligation* in the pricing currency. If the buyer's chosen stablecoin is off its peg (e.g. USDC @ \$0.97), the buyer pays proportionally **more** of that token so the **seller still receives the full value**.
- Buyers see a **human-readable amount** when one is within 3% of the exact figure.
- The amount is **never trusted from the client** — it is derived from the seller's offer price + oracle rates on the server.
## 2. Current state (as built)
From the promoted backend branch (`integrate-main-into-development` at `3a50dc4`):
| Concern | Where | Note |
|---|---|---|
| Seller price (invoice) | `src/models/SellerOffer.ts:9-12,53-64` | `price.amount` + `price.currency` enum `['USD','EUR','IRR','TRY','USDT','USDC']`**source of truth** |
| Buyer budget | `src/models/PurchaseRequest.ts:169-182` | now `['USDT','USDC']` only |
| Payment amount | `src/models/Payment.ts:31-40` | `amount.amount` + `amount.currency`; set at intent creation |
| Intent route | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | `/api/payment/request-network/intents` ignores client amount when `ORACLE_QUOTING_ENABLED=true`, loads the seller offer price, computes the quote, and uses `quote.settleAmount` |
| Provider dispatch | same, `:410-413` | `amn.scanner` vs Request Network |
| Token decimals | `src/services/payment/requestNetwork/tokens.ts` (`lookupTokenBySymbol`) | per-chain decimals (BSC 18, ETH 6, …) |
| Unit conversion | `src/utils/currencyUtils.ts:75,94` | `tokenToBlockchainUnits` / `blockchainUnitsToToken`; **stablecoins assumed 1:1 USD** (`:48`) |
| Seller allowlist | `src/services/payment/sellerPaymentConfig.ts:44-125` | `resolveSellerPaymentConfig` + `assertPaymentChoiceAllowed` |
**Two gaps this feature closes:** (a) no FX/oracle layer — stablecoins were hard-assumed 1:1 with USD; (b) the provider-selection settlement amount was **client-supplied** (a fund-safety hole independent of depeg).
## 3. Locked design decisions
| # | Decision | Choice |
|---|---|---|
| Depeg policy | **Protect seller, cap downside.** Token < \$1 ⇒ buyer pays more (seller made whole). Token > \$1 ⇒ par-neutral pass-through (buyer pays less). Depeg beyond a **hard cap****block + require re-confirm**, never silently overcharge. |
| Rounding | **Snap up within 3%**, or down **only if the result still fully covers** the depeg-protected obligation. Rounding may **never** leave the seller short. |
| Oracle | **Chainlink on-chain** for stablecoin/USD + major FX, with a **pluggable off-chain TS provider** fallback for exotic fiat (IRR, TRY). Cross-source agreement + staleness guard. |
| Scope/branch | Originally built on `feat/oracle-depeg-protection`, then backported onto the Postgres branch and promoted to backend `integrate-main-into-development` at `3a50dc4`. |
## 4. The quote math
All arithmetic in **decimal** (decimal.js / PG numeric) — never JS float (consistent with the money-core migration's decimal-string contract).
```
invoiceUSD = offer.amount × fxRate(offer.currency → USD) # FX oracle; for USDT/USDC pricing, ≈1 but still depeg-checked
tokenPriceUSD = depegRate(settlementToken → USD) # depeg oracle (Chainlink T/USD)
rawSettle = invoiceUSD / tokenPriceUSD # depeg protection
# premium pass-through is par-neutral: if tokenPriceUSD > 1, rawSettle < invoice (buyer benefits)
settle = niceRound(rawSettle) if niceRound(rawSettle) ≥ rawSettle*(1) AND |nice raw|/raw ≤ 0.03
= rawSettle otherwise
onChainUnits = tokenToBlockchainUnits(settle, token, chainId) # per-chain decimals
```
**niceRound ladder** (scaled to magnitude of `rawSettle`): candidates from `{1, 2, 5}×10^k` and the nearest `10^k / 100|10|1` step; pick the smallest nice number `≥ rawSettle` within 3%, else the nearest nice number `≥ obligation`. Never returns a value `< rawSettle`'s underlying fiat obligation.
**Guardrails:**
- **Staleness:** each rate has `fetchedAt`; reject/refresh if older than `ORACLE_MAX_STALENESS_S`.
- **Circuit breaker:** if `|1 tokenPriceUSD| > DEPEG_HARD_CAP_BPS` (e.g. 500 bps), **do not auto-quote** — return a `DEPEG_LIMIT_EXCEEDED` error; checkout must re-confirm or wait.
- **Cross-source agreement:** if two providers disagree by more than `ORACLE_DISAGREE_BPS`, treat as untrusted (block or fall back to the more authoritative source).
- **FX sanity bounds** per fiat (esp. IRR free-market vs official): configurable min/max plausible band.
## 5. Oracle abstraction
```ts
// src/services/payment/priceOracle/types.ts
export interface Rate { base: string; quote: string; price: string; decimals: number; fetchedAt: number; source: string }
export interface PriceProvider {
id: string
supports(base: string, quote: string): boolean
getRate(base: string, quote: string): Promise<Rate> // throws on unsupported / stale / unreachable
}
```
- `ChainlinkProvider` — reads on-chain aggregator feeds per chain (feed address registry, like `tokens.json`). Covers `USDC/USD`, `USDT/USD`, `EUR/USD`, etc.
- `OffchainFxProvider` — pluggable HTTP/TS snippet provider for fiat without on-chain feeds (IRR, TRY). Config-driven endpoint(s).
- `PriceOracle` aggregator — routes each pair to the best provider, applies fallback order, staleness + cross-source agreement, returns a single trusted `Rate` (or throws).
Providers are registered in a small registry (same pattern as the payment-provider registry) so adding a source = adding a file, no core changes — satisfies "oracle can be a TypeScript snippet."
## 6. Quote lifecycle & storage
1. At `POST …/intents`, **ignore any client `amount`**; load the `SellerOffer` price (server-authoritative).
2. Validate `(token, chain)` against the seller allowlist (`assertPaymentChoiceAllowed`).
3. `PriceOracle``fxRate`, `tokenPriceUSD`; run guardrails.
4. Compute `rawSettle``settle` (rounding) → `onChainUnits`.
5. Persist a **locked quote** in Postgres and mirror it on the Mongo Payment, then use `settle` as the intent amount.
**Payment quote fields** (Mongo mirror plus Postgres `payment_quotes` row):
```
quote: {
quoteId, pricingCurrency, offerAmount, invoiceUSD,
fxRate, fxSource, tokenPriceUSD, depegSource,
rawSettleAmount, settleAmount, roundingBps, depegAdjustmentBps,
token, chainId, fetchedAt, expiresAt
}
```
- **Validity window** `QUOTE_VALIDITY_S` (default 60120 s). On expiry → re-quote before submit; never settle against a stale quote.
- The quote is **immutable once a payment is detected** (audit trail of exactly what rate the buyer agreed to).
## 7. Data-model changes (Postgres-native)
Because the feature was promoted through the money-core migration branch, the quote is stored **natively in Postgres** via the Drizzle schema/repos:
- **Drizzle schema**: `payment_quotes` child table keyed by `payment_id -> payments.id` — decimal columns (`numeric(38,18)`) for `offer_amount`, `invoice_usd`, `fx_rate`, `token_price_usd`, `raw_settle_amount`, `settle_amount`; text for currencies/sources; `rounding_bps`, `depeg_adjustment_bps`, `fetched_at`, `expires_at`. Additive migration `0008`, preserving every `0005`/`0006` money-safety object.
- **Pricing-currency enum**: extend `budget_currency` / the offer currency enum to add `TRY` (and any others) — additive.
- **Mongoose `Payment`**: mirror the `quote` sub-document so dual-write stays consistent during migration.
- The quote write goes through `quoteRepo.persistQuoteForMongoPayment()`, resolving the PG parent through `payments.legacy_object_id` and then `id_map`. If the PG payment row is not present yet, the backend still mirrors the quote to Mongo and records a `pg_dualwrite_gaps` row for reconciliation.
## 8. Integration seam
`src/services/payment/requestNetwork/requestNetworkRoutes.ts`, before provider dispatch — for **both** the `amn.scanner` and Request Network paths when `ORACLE_QUOTING_ENABLED=true`:
```
- amount: Number(amount) // REMOVE: client-trusted
+ const quote = await priceOracle.quote({ offer, token, network, sellerConfig })
+ // amount := quote.settleAmount ; attach quote to the Payment + intent input
```
Request Network already takes `invoiceCurrency` + `paymentCurrency` (`requestNetworkService.ts:140-149`) — we still compute and store our own quote for depeg auditing and to keep AMN/RN consistent.
## 9. Config (env)
```
ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx # order = fallback order
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500 # block beyond 5% depeg
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL= # IRR/TRY source
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1= # Ethereum Chainlink reads
CHAINLINK_RPC_56= # BSC Chainlink reads
```
## 10. Fund-safety considerations
- **Server-authoritative amount** — client `amount` is ignored; derived from offer + oracle (closes the existing trust hole at `:404`).
- **Decimal-exact** end-to-end (no float), matching the money-core contract.
- **Quote tamper / replay** — quote is server-computed, stored, time-boxed, and frozen once payment detected.
- **Oracle manipulation** — cross-source agreement, staleness, hard caps, FX sanity bands; prefer Chainlink (manipulation-resistant) for stablecoins.
- **Rounding never shorts the seller** — `settle ≥ obligation`.
- **Premium handling is par-neutral** — buyer benefits when token > \$1, seller never overcharged.
## 11. Failure modes
| Failure | Behavior |
|---|---|
| All providers down / stale | Block checkout with a clear retry error (no guessed rate) |
| Depeg > hard cap | `DEPEG_LIMIT_EXCEEDED` — require explicit re-confirm |
| Provider disagreement | Use authoritative source or block |
| Quote expired at submit | Re-quote; if materially changed, re-confirm with buyer |
## 12. Testing
- Unit: quote math (depeg up/down, premium par-neutral, rounding-up-only, never-below-obligation), niceRound ladder across magnitudes (IRR millions → USDC cents).
- Oracle: provider fallback, staleness, disagreement, hard-cap circuit breaker.
- Property: `settle × tokenPriceUSD ≥ invoiceUSD` always (seller made whole); rounding error ≤ 3%.
- Integration: both `/intents` paths produce a stored quote; client-sent amount is ignored.
## 13. Implementation status
- **Oracle core** — `PriceProvider` interface, registry, `PriceOracle` aggregator, off-chain FX provider, Chainlink provider, and env-driven provider order are implemented.
- **Quote engine** — decimal math, depeg policy, nice rounding, guardrails, `Payment.quote` mirror, `payment_quotes`, and `TRY` pricing support are implemented.
- **Seam wiring** — `/intents` computes the server-side amount for both provider paths when `ORACLE_QUOTING_ENABLED=true`.
- **Tests** — oracle/depeg, request-network pay-in, adapter, webhook, and sweep service suites passed during promotion. PG decimal integration cases require local `PG_URL` / `MIGRATION_PG_URL` to run.
## 14. Open questions
- IRR: official vs free-market rate source (and which is "truth" for invoicing)?
- Do we expose the live quote (rate, depeg %, expiry) to the buyer UI before they confirm?
- Re-confirm threshold when a re-quote moves the amount (e.g. > X bps)?
```

View File

@@ -6,7 +6,7 @@ aliases: [Payment Record, Escrow, IPayment]
# Payment
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-05-31 — added AMN scanner provider, oracle quote mirror, and Postgres `payment_quotes` linkage.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The current model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one collection hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
@@ -17,11 +17,8 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
> [!warning] Mixed types
> `purchaseRequestId`, `sellerOfferId`, and `sellerId` use `Schema.Types.Mixed`. They are usually `ObjectId`s, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.
> [!warning] `provider` values (schema enum vs reality)
> The declared schema enum for `provider` is only `['request.network', 'other']`, yet production code writes additional values. The full set of providers that actually appear is: `request.network`, `shkeeper`, `decentralized`, `test`, `other`.
> - `paymentCoordinator.ts` and `RequestTemplateService.ts` create `Payment` docs with `provider: 'shkeeper'`.
> - The decentralized/on-chain flow uses `decentralized`.
> - ⚠️ **Frontend type bug:** the frontend `PaymentProvider` TypeScript type (`frontend/src/types/payment.ts`) is `'request.network' | 'test' | 'other'` — it is **missing `shkeeper` and `decentralized`**, so the client cannot represent payments created by those providers.
> [!note] `provider` values
> The current backend schema accepts `request.network`, `amn.scanner`, `shkeeper`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. Older docs and some frontend types may still mention historical values such as `test` or `decentralized`; treat those as legacy until their active routes are audited again.
> [!warning] `confirmed` vs `completed` — stats undercount
> Payment stats (`paymentService.getPaymentStats`) only increment `successfulPayments` for status **`confirmed`**:
@@ -43,7 +40,7 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
| `amount.amount` | Number | yes | — | — | — | Numeric amount. |
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
| `provider` | String | no | `request.network` | enum (declared): `request.network` / `other`. Values written in practice: `request.network`, `shkeeper`, `decentralized`, `request.network`, `test`, `other` | yes (compound, partial) | Payment processor. ⚠️ See provider note below — code writes `shkeeper` and `decentralized` even though they are not in the declared schema enum, and the frontend `PaymentProvider` type is missing both. |
| `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `other` | yes (compound, partial) | Payment processor. `amn.scanner` is the in-house scanner pay-in rail. |
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
| `blockchain.network` | String | no | — | — | — | Network identifier. |
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
@@ -81,6 +78,22 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
| `metadata.error` | String | no | — | — | — | Last error message. |
| `metadata.failedAt` | Date | no | — | — | — | When it failed. |
| `quote.quoteId` | String | no | — | — | — | PG `payment_quotes.id` when a Postgres quote row exists. |
| `quote.pricingCurrency` | String | no | — | — | — | Seller offer currency used for the quote. |
| `quote.offerAmount` | String | no | — | decimal string | — | Seller obligation in `pricingCurrency`. |
| `quote.invoiceUSD` | String | no | — | decimal string | — | `offerAmount × fxRate` at quote time. |
| `quote.fxRate` | String | no | — | decimal string | — | Pricing currency to USD rate. |
| `quote.fxSource` | String | no | — | — | — | FX provider id. |
| `quote.tokenPriceUsd` | String | no | — | decimal string | — | Settlement token USD price used for depeg protection. |
| `quote.depegSource` | String | no | — | — | — | Depeg/token-price provider id. |
| `quote.rawSettleAmount` | String | no | — | decimal string | — | Exact `invoiceUSD / tokenPriceUsd` before rounding. |
| `quote.settleAmount` | String | no | — | decimal string | — | Final token amount after seller-protective rounding. |
| `quote.roundingBps` | Number | no | — | integer bps | — | Upward rounding applied. |
| `quote.depegAdjustmentBps` | Number | no | — | integer bps | — | Absolute deviation from USD peg. |
| `quote.token` | String | no | — | — | — | Settlement token symbol. |
| `quote.chainId` | Number | no | — | — | — | Settlement chain id. |
| `quote.fetchedAt` | Date | no | — | — | — | Oracle rate timestamp. |
| `quote.expiresAt` | Date | no | — | — | — | Quote expiry. |
| `createdAt` | Date | auto | `Date.now` | — | yes (compound) | Mongoose timestamp. |
| `processedAt` | Date | no | — | — | — | When processing started. |
| `completedAt` | Date | no | — | — | — | When fully settled. |
@@ -106,6 +119,10 @@ Defined at `backend/src/models/Payment.ts:174-188`:
- `{ providerPaymentId: 1 }` (sparse) — provider idempotency.
- `{ buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }` (unique, partial: `provider === 'shkeeper' && direction === 'in' && status === 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
## Postgres Quote Table
The Postgres money-core branch stores oracle quotes in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)` and the route resolves the PG parent through `payments.legacy_object_id` or `id_map` during the Mongo/PG dual-write window. If the PG payment row is missing, the quote is mirrored to this Mongo `quote` subdocument and a `pg_dualwrite_gaps` row is recorded for reconciliation.
## Pre/Post Hooks
None declared.

View File

@@ -6,7 +6,7 @@ aliases: [Seller Offer, Bid, ISellerOffer]
# SellerOffer
> **Last updated:** 2026-05-30 — added AML fields (`requireAmlCheck`, `amlBlockOnFailure`)
> **Last updated:** 2026-05-31 — added `TRY` pricing support for oracle/depeg quoting.
A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`.
@@ -23,10 +23,10 @@ A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the del
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. |
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. |
| `price.amount` | Number | yes | — | min 0 | — | Quoted amount. |
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Quote currency. |
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `TRY` / `USDT` / `USDC` | — | Quote currency. `TRY` is supported by the oracle/depeg path through the off-chain FX provider. |
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. |
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. |
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` | yes | Offer status. |
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` / `active` | yes | Offer status. |
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
| `notes` | String | no | — | trim | — | Internal/private notes. |
| `validUntil` | Date | no | — | — | — | Expiration. |
@@ -35,7 +35,7 @@ A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the del
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
> **Status enum note:** Valid values are `pending | accepted | rejected | withdrawn` only. `'active'` is **not** a valid status and would throw a Mongoose `ValidationError` if passed.
> **Status enum note:** `active` is accepted by the current backend schema for marketplace/listing flows, in addition to the negotiation statuses `pending | accepted | rejected | withdrawn`.
## Virtuals

View File

@@ -5,7 +5,7 @@ tags: [api, payment, reference, request-network, escrow]
# Payment API
> **Last updated:** 2026-05-30AMN Pay Scanner integration, on-demand RN reconcile in GET /payment/:id, pay-in route renamed, reload/probe routes now implemented
> **Last updated:** 2026-05-31Postgres integration promotion, oracle quote persistence, AMN scanner rail-switch fix, and partial gasless permit endpoints.
The payment surface is split across provider-neutral payment routers, Request Network checkout/webhook routes, derived-destination custody routes, and admin safety routes:
@@ -156,7 +156,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/request-network/pay-in
**Description:** Creates a Request Network pay-in intent and stores a [[Payment]] with `provider: "request.network"`. The service can attach a per-payment derived destination before creating the provider request. This is the **current active route** (mounted at `/api/payment/request-network/pay-in`). The `/intents` path listed in older docs is an alias; use `pay-in` for new integrations.
**Description:** Creates a plain Request Network pay-in intent and stores a [[Payment]] with `provider: "request.network"`. The service can attach a per-payment derived destination before creating the provider request. This route stays available at `/api/payment/request-network/pay-in`, but new provider-selection checkout integrations should prefer `/api/payment/request-network/intents`.
**Auth required:** Bearer JWT (buyer)
**Request body:**
```ts
@@ -172,6 +172,29 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
```
**Response 200:** `{ success: true, data: { paymentId, paymentUrl, providerPaymentId, raw, ... } }`
### POST /api/payment/request-network/intents
**Description:** Richer buyer intent endpoint used by the provider-selection checkout. It can dispatch either to `request.network` or `amn.scanner`, validates the seller's allowed chain/token choices, and re-points an existing pending intent when the buyer changes rail. When `ORACLE_QUOTING_ENABLED=true`, the backend ignores client-supplied `amount`, loads the seller offer price, computes a depeg-protected quote, and uses the computed settlement amount for the provider intent.
**Auth required:** Bearer JWT (buyer)
**Request body additions:** `provider?: "request.network" | "amn.scanner"`, `token`, `network`, `metadata.templateId?`.
**Response 200:** `{ success: true, data, quote? }`. `quote` includes `settleAmount`, `token`, `tokenPriceUSD`, `depegAdjustmentBps`, `roundingBps`, and `expiresAt` when oracle quoting is enabled.
**Errors:** `400` for unsupported/disallowed chain-token choice, `422 DEPEG_LIMIT_EXCEEDED` when the settlement token exceeds the depeg hard cap, `503 ORACLE_UNAVAILABLE` when rates are stale or unavailable.
### GET /api/payment/request-network/permit-availability
**Description:** Checks whether the backend relayer can sponsor an EIP-2612 `permit()` transaction for a chain/token. This is partial gasless support: it removes the approval transaction gas only; the buyer still sends the final payment transaction.
**Auth required:** Bearer JWT
**Query params:** `chainId`, `token`
**Response 200:** `{ success: true, data: { available, reason?, relayer?, balanceWei?, requiredWei? } }`
### POST /api/payment/request-network/:paymentId/permit
**Description:** Broadcasts a buyer-signed EIP-2612 permit through the backend relayer. The route validates the permit against the payment's actual in-house checkout block so the relayer only sponsors real pending payments and the expected fee-proxy spender.
**Auth required:** Bearer JWT (buyer who owns the payment)
**Request body:** `{ owner, spender, value, deadline, v, r, s }`
**Response 200:** `{ success: true, data: { txHash, allowance } }`
**Limitations:** Only permit-capable tokens/chains qualify. Mainnet USDT is not permit-capable; full gasless payment still requires a forwarder or account-abstraction/paymaster design.
### GET /api/payment/request-network/:paymentId/checkout
**Description:** Rehydrates the in-house checkout payload for an existing Request Network payment so the frontend can build the on-chain approval/payment transaction without relying on the hosted RN page.

View File

@@ -128,6 +128,24 @@ Request Network is the current primary payment provider. See [[PRD - Request Net
---
## Payments — Oracle Quoting / Depeg Protection
| Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------|
| `ORACLE_QUOTING_ENABLED` | backend | optional | `false` | `true` | Enables server-authoritative seller-offer quoting on `/api/payment/request-network/intents`. |
| `PRICE_ORACLE_PROVIDERS` | backend | optional | `chainlink,offchain_fx` | `chainlink,offchain_fx` | Ordered provider list used by the quote engine. |
| `ORACLE_MAX_STALENESS_S` | backend | optional | `120` | `120` | Rejects stale FX/token rates. |
| `ORACLE_DISAGREE_BPS` | backend | optional | `100` | `100` | Maximum allowed provider disagreement before the quote is blocked. |
| `DEPEG_HARD_CAP_BPS` | backend | optional | `500` | `500` | Blocks automatic quoting beyond this stablecoin depeg. |
| `QUOTE_VALIDITY_S` | backend | optional | `90` | `90` | Quote expiry window. |
| `REQUOTE_RECONFIRM_BPS` | backend | optional | `50` | `50` | Frontend/backend threshold for buyer reconfirmation after a material re-quote. |
| `OFFCHAIN_FX_URL` | backend | conditional | — | `https://fx.example/rates` | Required when `offchain_fx` is enabled for fiat currencies without Chainlink coverage. |
| `OFFCHAIN_FX_REQUEST_TIMEOUT_MS` | backend | optional | `8000` | `8000` | HTTP timeout for the off-chain FX provider. |
| `CHAINLINK_RPC_1` | backend | conditional | — | `https://...` | Ethereum RPC for Chainlink stablecoin/USD reads. |
| `CHAINLINK_RPC_56` | backend | conditional | — | `https://...` | BSC RPC for Chainlink stablecoin/USD reads. |
---
## Payments — Wallet UI (frontend)
| Name | Repo | Required | Default | Example | Purpose |
@@ -331,6 +349,20 @@ AMN_SCANNER_URL=
AMN_SCANNER_WEBHOOK_SECRET=
AMN_SCANNER_DEFAULT=false
# Oracle quoting / stablecoin depeg protection
# Keep disabled until feeds and the off-chain FX source are configured.
ORACLE_QUOTING_ENABLED=false
PRICE_ORACLE_PROVIDERS=chainlink,offchain_fx
ORACLE_MAX_STALENESS_S=120
ORACLE_DISAGREE_BPS=100
DEPEG_HARD_CAP_BPS=500
QUOTE_VALIDITY_S=90
REQUOTE_RECONFIRM_BPS=50
OFFCHAIN_FX_URL=
OFFCHAIN_FX_REQUEST_TIMEOUT_MS=8000
CHAINLINK_RPC_1=
CHAINLINK_RPC_56=
# OAuth
GOOGLE_CLIENT_ID=
```

View File

@@ -11,6 +11,20 @@ entries on top. Maintained by agents per the rule in `../AGENTS.md`.
---
### 2026-05-31 — backend@3a50dc4 — promote Postgres integration branch with oracle/depeg + gasless backports
**Commits:** backend `11bfd02` `74d73c5` `1730c4d` `148c803` `8aa4473` `a5e4da2` `3a50dc4` (backend `2.6.76``2.6.79`)
**Touched:**
- Branches: preserved old `integrate-main-into-development` as `integrate-main-into-development-old`; promoted `feat/pg-money-core-migration` to `integrate-main-into-development`.
- Payment routing: `requestNetworkRoutes.ts`, `requestNetworkPayInService.ts`, `amnScannerPayInService.ts`, `permitRelay.ts`, `amnPayAdapter.ts`
- Oracle/depeg: `priceOracle/*`, `paymentQuote.ts`, migration `0008_giant_winter_soldier.sql`, `Payment.ts`, `SellerOffer.ts`
- Tests/config: `oracle-depeg-protection.test.ts`, `request-network-payin.test.ts`, `.env.example`, `package.json`, `package-lock.json`
**Why:** Combine the old integration branch's AMN scanner rail-switch fix and partial gasless permit work with the Postgres money-core branch and oracle/depeg quote engine. The final fix resolves the PG payment id through `payments.legacy_object_id` / `id_map` before writing `payment_quotes`, records `pg_dualwrite_gaps` if PG is behind, and keeps the Mongo quote mirror coherent during dual-write.
**Verification:** `npm run typecheck -- --pretty false`; `npm test -- --runInBand __tests__/oracle-depeg-protection.test.ts`; `npm test -- --runInBand __tests__/request-network-payin.test.ts __tests__/request-network-adapter.test.ts __tests__/request-network-webhook.test.ts __tests__/sweep-service.test.ts`. The PG decimal integration cases in the oracle suite skipped because no local `PG_URL`/`MIGRATION_PG_URL` was configured.
**Linked docs updated:** [[Payment]], [[SellerOffer]], [[Payment API]], [[Environment Variables]], [[Oracle Pricing & Stablecoin Depeg Protection]], [[PRD - Gasless Buyer Payments (Roadmap)]]
---
### 2026-05-30 — frontend@9013b70, c77cf82, 8add494 — staged node-package upgrade + TS6 test fix + lint sweep
**Commits:** `8add494` `c77cf82` `9013b70`

View File

@@ -0,0 +1,50 @@
# PRD — Gasless Buyer Payments (Roadmap)
Status: **Roadmap / future improvement** for full gasless payments. The partial permit-approval relay shipped on backend `integrate-main-into-development` at `3a50dc4`.
## Background
The in-house checkout (Request Network fee-proxy + amn.scanner) has the buyer:
1. **approve** the RN fee-proxy to spend their token (on-chain tx, gas), then
2. **pay** via `transferFromWithReferenceAndFee` (on-chain tx, gas).
We want the buyer to pay **gasless** (sign only, never spend native gas) when the
token supports it.
## Partial (shipped — permit-approval only)
For EIP-2612 permit-capable tokens (USDC on mainnet/Base/Arbitrum/Polygon — see
`PERMIT_CAPABLE_TOKENS` in `sweepService.ts`; **mainnet USDT has NO permit**):
- Buyer signs an EIP-2612 **permit** (gasless signature) granting allowance to the
fee-proxy.
- A backend **relayer** broadcasts `token.permit(...)` (relayer pays that gas).
- Buyer still pays gas for the **transfer** (`transferFromWithReferenceAndFee`).
Net: removes the *approve* tx gas only. USDC-only. The buyer still sends 1 tx.
## Full gasless (THIS roadmap item — NOT done)
**Blocker:** `transferFromWithReferenceAndFee` pulls tokens from **`msg.sender`**,
so a relayer calling it would pull from the *relayer*, not the buyer. A relayer
cannot broadcast the payment on the buyer's behalf with the current contract.
To make the buyer fully gasless (sign only), build ONE of:
1. **Meta-tx forwarder / custom payment proxy** — a contract that accepts a buyer
EIP-2612 permit + a signed payment intent, then `transferFrom(buyer, …)` while
the **relayer** is `msg.sender` and sponsors gas. Requires a deployed,
audited contract + funded relayer wallet + replay/abuse protection.
2. **ERC-4337 account abstraction + paymaster** — buyer ops sponsored by a
paymaster. Requires bundler + paymaster funding + smart-account UX.
### Requirements / open questions
- Deployed contract (forwarder or AA stack) per supported chain.
- Funded relayer/paymaster wallet; gas-cost accounting (who eats the gas, caps).
- Abuse controls: bind each sponsored op to a real pending payment
(paymentId + buyer + spender + amount), rate-limit, deadline.
- Non-permit tokens (mainnet USDT) can never be permit-gasless — needs AA or a
pre-funded-allowance flow.
### Out of scope
- The partial permit-approval flow (separate, smaller change).
- Production relayer funding/ops.