diff --git a/01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md b/01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md new file mode 100644 index 0000000..d7dc418 --- /dev/null +++ b/01 - Architecture/Oracle Pricing & Stablecoin Depeg Protection.md @@ -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 // 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 60–120 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)? +``` diff --git a/02 - Data Models/Payment.md b/02 - Data Models/Payment.md index 9be8055..425932a 100644 --- a/02 - Data Models/Payment.md +++ b/02 - Data Models/Payment.md @@ -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. diff --git a/02 - Data Models/SellerOffer.md b/02 - Data Models/SellerOffer.md index 9cd07bf..1a0b84c 100644 --- a/02 - Data Models/SellerOffer.md +++ b/02 - Data Models/SellerOffer.md @@ -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 diff --git a/03 - API Reference/Payment API.md b/03 - API Reference/Payment API.md index 24bd3a8..fd33fc5 100644 --- a/03 - API Reference/Payment API.md +++ b/03 - API Reference/Payment API.md @@ -5,7 +5,7 @@ tags: [api, payment, reference, request-network, escrow] # Payment API -> **Last updated:** 2026-05-30 — AMN Pay Scanner integration, on-demand RN reconcile in GET /payment/:id, pay-in route renamed, reload/probe routes now implemented +> **Last updated:** 2026-05-31 — Postgres 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. diff --git a/07 - Development/Environment Variables.md b/07 - Development/Environment Variables.md index 8e8e3b2..c78a692 100644 --- a/07 - Development/Environment Variables.md +++ b/07 - Development/Environment Variables.md @@ -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= ``` diff --git a/09 - Audits/Activity Log.md b/09 - Audits/Activity Log.md index 62c5915..f89b894 100644 --- a/09 - Audits/Activity Log.md +++ b/09 - Audits/Activity Log.md @@ -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` diff --git a/PRD - Gasless Buyer Payments (Roadmap).md b/PRD - Gasless Buyer Payments (Roadmap).md new file mode 100644 index 0000000..8df393e --- /dev/null +++ b/PRD - Gasless Buyer Payments (Roadmap).md @@ -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.