Merge remote-tracking branch 'origin/main'

This commit is contained in:
moojttaba
2026-05-27 20:06:42 +03:30
5 changed files with 357 additions and 0 deletions

18
.gitleaks.toml Normal file
View File

@@ -0,0 +1,18 @@
title = "nick-doc gitleaks config"
[extend]
useDefault = true
# 'Pangolin/Newt' is the literal product name of a self-hosted tunneling tool
# mentioned in operational handoff docs, not a secret. The generic-api-key
# rule fires on entropy heuristics for the surrounding line.
[[allowlists]]
description = "Documentation-only false positives"
regexes = [
'''Pangolin/Newt''',
]
# Pin the known historical finding so we don't mask future leaks in the file.
[[allowlists]]
description = "Pre-existing FP in Telegram Mini App handoff doc, 2026-05-24"
commits = ["940ad0c655777e3bf6d5416fd2829be77bdfc4f8"]

View File

@@ -0,0 +1,157 @@
# Request Network Integration — Constraints and Design Implications
**Date:** 2026-05-27
**Status:** Active concerns; mitigations partially designed, partially blocked on RN clarifications
**Owners:** Backend payments (Amanat), product
This document captures four payment-flow issues that surfaced while integrating Request Network (RN) into the Amanat escrow stack. Each one is either a show-stopper or a non-trivial architectural constraint. Listed in priority order.
---
## 1. RN does not support Rabby — show-stopper for our wallet user base
### Problem
RN's hosted payment page (the `pay.request.network/?token=…` UI returned by `/v2/secure-payments`) does not detect / connect to Rabby. A meaningful slice of Amanat's user base pays from Rabby. Sending them to a screen that won't even let them connect is a hard block.
### Mitigation (designed, not yet implemented)
Skip the RN-hosted UI. We already call `/v2/secure-payments` and receive a `securePaymentUrl`, but we also receive `requestIds` and `token` — that's everything we need to know what the merchant request is. Behind that token there is a contract on the destination chain that anyone can fulfill.
So the new flow becomes:
1. Backend calls RN `/v2/secure-payments` (same as today) and stores the `requestIds[0]` + destination wallet + amount + token on our `Payment` doc.
2. **We render our own checkout screen** that:
- Shows the buyer the wallet address to pay to (the destination resolved from the merchant reference / chain / token).
- Lets the buyer connect *any* wallet — Rabby, MetaMask, OKX, Phantom-bridged, WalletConnect.
- Builds the transfer transaction client-side (standard ERC-20 transfer) and asks the wallet to sign.
3. RN's webhook (`/v2/request/{id}`-style polling fallback) tells us when the payment lands.
### Why this is acceptable
- RN's value to us at that point is the *settlement bookkeeping*, not the UI. We use them as "did this address receive the expected amount before timeout?" — the wallet UX stays in our control.
- Buyer never sees a third-party brand mid-checkout, which is a UX win regardless of Rabby.
### Open
- Need to confirm RN actually settles a payment that arrives from a *transaction we built*, not from their hosted page. Their pricing/fees may be tied to going through their UI. **Test required** before committing to this path.
- Need a fallback for the buyer who insists on the RN hosted UI (some users will already have the link copied). Keep `securePaymentUrl` exposed as a "advanced / pay with RN" link.
---
## 2. RN's multi-chain routing forces an expensive LiFi bridge
### Problem
When we configure a destination route (e.g. BSC + USDC), RN's hosted UI still lets the buyer pick *any* chain where they hold funds (e.g. ARB). To honor that, RN routes the buyer's funds through **LiFi**, which charges bridging fees that **someone has to pay**, and it's not clearly disclosed who.
The visible costs:
- Buyer over-pays vs. nominal invoice amount (bad UX).
- Or we eat the spread (bad margin).
- Or seller gets less than they expected (worst — they'll dispute).
- Plus settlement latency goes from seconds to minutes-hours depending on the bridge.
### Mitigation (designed)
Take the chain choice away from RN's UI and bring it into ours, gated by what the *seller* will accept.
Two-step UX:
1. **At offer creation (seller side):** seller specifies which chain(s) they accept payouts on. We persist this as `acceptedChains: [bsc, arb, base, …]` on the offer / merchant configuration.
2. **At checkout (buyer side, before any RN call):** we show the buyer the seller's accepted chains. Buyer picks one. *Then* we call RN with that exact chain pinned as the destination. No LiFi bridge — same-chain transfer.
### Side benefit
This composes cleanly with #1 (own checkout screen): we already have to render the wallet picker, so adding a chain selector before the wallet step costs almost nothing.
### Open
- We need a per-seller config table for accepted chains. Today the env-level `REQUEST_NETWORK_MERCHANT_REFERENCE` hard-codes a single chain (`bsc`). Needs to become per-seller, per-offer.
- Does RN's API support creating a secure-payment that *rejects* off-chain payments rather than auto-bridging? Or do we have to enforce this purely on our side by never offering the cross-chain option to the buyer? **Confirm with RN docs/support.**
---
## 3. Sanctioned-funds risk — single escrow wallet poisons the entire platform
### Problem
Today the entire escrow stack receives funds into one (or a handful of) wallets — `REQUEST_NETWORK_MERCHANT_REFERENCE` resolves to a single destination address. If a buyer pays with funds tied to a sanctioned source / mixer / known-bad address:
- That destination wallet gets tagged non-compliant by Chainalysis / TRM / Elliptic.
- Downstream exchanges and OTC desks won't accept transfers from it.
- One bad buyer can effectively brick the entire platform's settlement layer.
This is a show-stopper for going live at scale. Same class of issue we already considered around SHKeeper.
### Mitigation (designed; needs RN feasibility check)
Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-generated address that only ever receives that one transaction. If those funds turn out to be dirty:
- Only that wallet is tainted.
- We never sweep it into our main treasury (or sweep only after the payment passes screening).
- Risk is **siloed to the individual escrow**, not platform-wide.
### What this requires (architectural work)
1. **Wallet abstraction layer** — service that on demand generates a fresh address (HD wallet derivation from a master seed kept in a hardware module / KMS) and returns it to the payment-intent flow.
2. **Address book / registry** — maps `(paymentId, chainId)` → derived address. Persists derivation path + sequence number so we can reproduce keys for sweeps later.
3. **Sweep job** — once a payment is confirmed AND has passed an on-chain screening check (Chainalysis API or similar), sweep the ephemeral wallet to the main treasury. If screening fails, the ephemeral wallet is quarantined and the payment refunded out of band.
4. **Key custody policy** — these are still our funds in custody briefly; need clear policy on who can sign sweeps, hot-key vs cold-key separation.
### Critical open question
**Does RN support creating a secure-payment with a destination wallet we specify per-request, rather than a static merchant reference?** If yes, this is straightforward — we generate a wallet, register it as the destination for one specific `/v2/secure-payments` call, done. If no (RN only allows pre-registered destinations), we have to either:
- Pre-register a large pool of addresses with RN and rotate through them, or
- Bypass RN's destination model and go full self-host (which is most of issue #4).
**Action: confirm with RN support whether per-request destinations are supported on the same API key.**
---
## 4. RN reduced to a notification service — viable, but not yet validated
### Problem statement
If we adopt #1 (own checkout UI), #2 (own chain selection), and #3 (own ephemeral wallets), RN's role in the flow collapses to:
> "Tell me when wallet X receives Y tokens (or doesn't, before timeout)."
Which is a *notification* primitive, not a payment platform. We'd be paying for a feature we're using maybe 5% of.
### Why this might still be worth it
- We get RN's chain watchers + reorg handling + webhook reliability for free.
- We don't have to run our own indexer on n chains.
- Their screening (if they do any) is one more compliance layer.
### Why this might NOT be worth it
- Pricing built around hosted-UI usage, not API-only. May not be cost-effective at API-only volumes.
- We're outsourcing the *one thing* RN is good at (settlement) and keeping the parts they don't help with (UX, wallet generation, compliance).
- Alternative: do the same with our own chain watcher (Alchemy webhooks / Tenderly / Goldsky) and skip RN entirely.
### What needs testing before we commit
1. **Webhook reliability at our volume.** What's RN's SLA for "address received funds → webhook delivered"? P50? P99?
2. **Custom destination support.** See open question in #3.
3. **Per-API-key rate limits.** If we end up calling `/v2/secure-payments` once per escrow, do we hit ceilings?
4. **Pricing for the notification-only flow** — is there a tier, or is it the same as the full-stack price?
5. **What happens when the payment arrives from a transaction WE built** (not theirs)? Does the webhook still fire? Is settlement still recognized? — this is the load-bearing test for the whole strategy.
Until #5 is confirmed, the rest is just paper architecture.
---
## Cross-cutting next actions
| # | Action | Blocker / Owner |
|---|---|---|
| 1 | Test: payment via wallet-built transfer triggers RN webhook | Backend payments |
| 2 | Test: `/v2/secure-payments` accepts a per-request destination wallet | Backend payments |
| 3 | Confirm RN doesn't auto-bridge when buyer pays on the destination chain natively | Backend payments |
| 4 | Get RN's webhook P99 latency + delivery guarantees in writing | Product / RN account manager |
| 5 | Spec the wallet-abstraction layer (HD derivation + sweep job + key policy) | Backend, before going live |
| 6 | Spec the seller-side accepted-chains config | Backend + frontend |
Actions 14 are *information-gathering* and should run in parallel before any more architectural commitment to RN. Actions 56 are blocked on 13 confirming RN can actually support this shape.

View File

@@ -0,0 +1,130 @@
# Handover — Request Network Intent: Duplicate Key Bug
**Date:** 2026-05-27
**Endpoint:** `POST https://dev.amn.gg/api/payment/request-network/intents`
**Severity:** Blocks checkout retry flow on Request Network payments
**Status:** Reproducible in production (`dev.amn.gg`), root cause identified, three remediation paths proposed
---
## 1. Symptom
When a buyer attempts to create a Request Network payment intent from the checkout step 2 page, the backend returns:
```json
{
"success": false,
"error": "E11000 duplicate key error collection: marketplace.payments index: uniq_pending_request_network_by_buyer_session dup key: { buyerId: ObjectId('68e3a21fbc79e4364c20a07e'), purchaseRequestId: \"template-checkout-1779856632092\", provider: \"request.network\", direction: \"in\" }"
}
```
The raw MongoDB error is being surfaced to the client, which is a secondary issue (information leak + ugly UX).
## 2. Environment (verified correct)
The infrastructure side is **not the cause**. All env vars and dashboard config are aligned:
- `REQUEST_NETWORK_API_KEY` — matches the dashboard "amn" Client ID, status **Active**.
- `REQUEST_NETWORK_MERCHANT_REFERENCE` — matches dashboard destination wallet (`0x05E2…573e`) on BNB Chain, USDC token slug.
- `REQUEST_NETWORK_ORIGIN=https://dev.amn.gg` — present in the Client ID's Allowed Domains (along with `https://amn.gg`).
- Webhook endpoint `https://dev.amn.gg/api/payment/request-network/webhook` is registered and **Active** in the dashboard.
- `REQUEST_NETWORK_ENABLED=true`, `PAYMENT_PROVIDER_MODE=live`.
The Request Network API was never reached on this failure — the error happens **inside our backend** before any outbound call.
## 3. Root cause
The `payments` collection has a unique (partial) index:
```
uniq_pending_request_network_by_buyer_session
keys: { buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }
```
The frontend submits a `purchaseRequestId` of the form `template-checkout-<timestamp>`. In the failing request:
```
purchaseRequestId: "template-checkout-1779856632092"
buyerId: 68e3a21fbc79e4364c20a07e
provider: request.network
direction: in
```
A previous attempt — almost certainly a retry from the same page session — **already inserted a Payment document with this exact key tuple and left it in a pending state**. The unique index correctly rejects the second insert.
This is a logic bug in how the intent endpoint and the frontend handle retries, not a database bug. The index is doing exactly what it should: preventing duplicate pending intents for the same checkout session.
### Why it triggers in practice
- `purchaseRequestId` is generated client-side from a timestamp and **persisted in component/page state**, so it does **not** rotate on retry.
- If the first POST creates the Payment doc but the client then errors (network blip, validation issue elsewhere, double-click), the second POST collides.
- The backend treats the endpoint as create-only rather than idempotent, so it tries `insertOne` every time.
## 4. Reproduction
1. Sign in as `buyer@marketplace.com`.
2. Open `https://dev.amn.gg/dashboard/shops/checkout/?step=2` for a template checkout.
3. Submit the intent successfully (or simulate a half-complete request that creates the Payment doc).
4. Submit again from the same page state without regenerating `purchaseRequestId`.
5. Observe the E11000 response.
Exact payload that reproduces is captured in the original ticket (`amount: 12`, `token: "USDT"`, `network: "bsc"`, `sellerId: "6918535be9301e0e4358d83e"`).
## 5. Solutions
Three layered fixes. **(c) is implemented as of 2026-05-27** in `backend/src/services/payment/requestNetwork/{requestNetworkPayInService,requestNetworkRoutes}.ts` (`nick/backend@bdbcc32`). Apply (a) once to clear the existing stuck doc. (b) is a frontend hygiene improvement worth keeping on the backlog but is no longer required to unblock checkouts.
> **Secondary fix shipped at the same time (`nick/backend@40750d3`, 2.6.20).** Once the idempotency check passed, every call was failing with `Request Network secure payment creation failed: HTTP 400` because the adapter was sending a flat payload to `/v2/secure-payments`, while the v2 endpoint requires `{ reference, requests:[{ destinationId, amount, metadata? }], redirectUrl?, callbackUrl? }`. The translation now happens inside `createSecurePaymentRequest` (the rich internal payload object is still passed to the response mapper for `paymentCurrency`/`network` context). Verified end-to-end with `backend/scripts/smoke/rn-payload-shape.mjs` against the real RN API: HTTP 201 with `securePaymentUrl` + `requestIds[]`.
### a) Hot unblock — clear the stale pending document
Run in the Mongo shell against the `marketplace` database:
```js
db.payments.deleteOne({
buyerId: ObjectId("68e3a21fbc79e4364c20a07e"),
purchaseRequestId: "template-checkout-1779856632092",
provider: "request.network",
direction: "in",
status: { $in: ["pending", "initiated", "awaiting_payment"] }
});
```
Then retry the checkout. Use this only for the specific buyer/session being unblocked — do **not** broad-delete pending Payments.
### b) Frontend — rotate `purchaseRequestId` on every retry
Locate the checkout step 2 component that builds the `template-checkout-<timestamp>` id. Today this value is computed once and reused across retries. Change it so:
- A fresh id is generated every time the user lands on (or re-enters) step 2.
- A fresh id is generated when the user clicks "Pay" after a previous failure — i.e. tie generation to the click handler, not to mount, OR clear the cached value on any error response.
- Prefer a UUID/ULID over a timestamp to make the intent collision-proof even across rapid clicks.
This eliminates the collision from the client side and is the minimum fix.
### c) Backend — make `/intents` idempotent
The endpoint is semantically an *intent*: the same (buyer, purchaseRequest, provider, direction) tuple should always resolve to the same Payment document. Change the controller for `POST /api/payment/request-network/intents` to:
1. Look up an existing Payment matching `{ buyerId, purchaseRequestId, provider: "request.network", direction: "in" }` in any non-terminal status (`pending`, `initiated`, `awaiting_payment`).
2. If found, return that Payment (and its Request Network handoff data) with HTTP 200 — do **not** insert.
3. If not found, create the new Payment as today.
4. Wrap the insert in a try/catch on `E11000`; on collision, re-read and return the existing doc (handles the race between two concurrent requests).
This is the correct long-term shape and also defends against double-clicks, browser back/forward, and React strict-mode double-invocations.
Additionally:
- Stop returning raw Mongo error strings to the client. Map `E11000` on this collection to an HTTP 409 with a sanitized body like `{ success: false, code: "INTENT_ALREADY_EXISTS" }`.
- Log the raw error server-side only.
## 6. Out of scope (but worth noting)
- The webhook **Signing Secret** in the dashboard shows `Unavailable` for the active webhook. `REQUEST_NETWORK_WEBHOOK_SECRET` is set in env, but verify the value matches what the dashboard issued at webhook creation — if not, regenerate and update env. This will bite the next time a payment actually clears.
- The `amount: 12` is sent as `USDT` in the payload, but `REQUEST_NETWORK_PAYMENT_CURRENCY=USDC` and the merchant reference's token slug is the BSC USDC contract. Confirm whether the frontend should be sending `USDC` or whether the backend is supposed to normalize.
## 7. Suggested ownership
- **(a)** — Ops / whoever has Mongo access. One-shot.
- **(b)** — Frontend dev owning `dashboard/shops/checkout`.
- **(c)** — Backend dev owning `payment/request-network` controllers and the `Payment` model. This should land as a single PR with a regression test that fires two identical intent POSTs and asserts the second returns 200 with the same payment id.

View File

@@ -2,6 +2,13 @@
This documentation workspace uses Taskmaster as the source of truth for agent work. This documentation workspace uses Taskmaster as the source of truth for agent work.
## Repository Rules
- Repository-wide operating rules live in `../RTK.md`; follow them in addition to this file.
- For product or code changes that affect frontend or backend, keep `frontend` and `backend` package versions/build numbers bumped together and synchronized unless the user explicitly asks otherwise.
- Preserve Telegram Mini App auth retry behavior: `/api/auth/telegram` must accept repeated valid `initData` for the same launch session; replay rejection belongs only on one-time routes such as webhook/session creation.
- In the final response, mention version/build bumps and verification commands when they were part of the work.
## Taskmaster Workflow ## Taskmaster Workflow
- Before choosing implementation or documentation work, run `task-master next` from the repository root. - Before choosing implementation or documentation work, run `task-master next` from the repository root.

45
RTK.md Normal file
View File

@@ -0,0 +1,45 @@
# RTK
Repository rules agents must follow for Amanat escrow work.
## Version and Build Numbers
- **Every build of `frontend/` or `backend/` must bump the patch component (`Z` in `X.Y.Z`) by one.** Container images on `git.manko.yoga` are tagged from `package.json` version — a build with an unchanged version overwrites the previous image and erases history. Patch bump on every build, no exceptions.
- Bump together so frontend and backend stay aligned (e.g. both go `2.6.18 → 2.6.19`).
- Bumping `Y` (minor) or `X` (major) is only for explicit milestone releases the user requests; never as a side-effect of an ordinary build.
- For any product or code change that affects `frontend/` or `backend/`, bump both versions together before final response in:
- `frontend/package.json`
- `frontend/package-lock.json`
- tracked frontend env files that set `NEXT_PUBLIC_APP_VERSION`
- `backend/package.json`
- `backend/package-lock.json`
- Backend runtime/version reporting should read from `backend/package.json`, not a hardcoded fallback.
- Keep frontend and backend on the same version/build number unless the user explicitly asks otherwise.
- Do not bump versions for docs-only changes unless the user asks for a release/build number.
- Mention the resulting frontend and backend version numbers in the final response.
## Pre-Deploy CLI Verification
- For any backend or frontend change, run the focused CLI smoke test for the touched area **before pushing a commit that would trigger a build**. The image tracker patch-bumps per build, so a failed build still consumes a version slot.
- Smoke-test scripts live under `backend/scripts/smoke/*.sh` (and the equivalent frontend dir). `scripts/test-*` is in `.gitignore`, so put committed smoke tests in `scripts/smoke/`. They must accept `BASE_URL` so the same script can target a local backend, dev, or production.
- Confirm the script passes against a local backend (or, where local isn't feasible, an explicitly named target) before pushing. After the deploy completes, re-run the same script against the deployed URL to confirm production behavior matches.
- If no smoke-test script exists for the touched area, create one as part of the change.
## CI Notification Safety
- Telegram CI notifications (`appleboy/drone-telegram` in `.woodpecker/*.yml`) must HTML-escape commit messages and strip git trailers (`Co-Authored-By:`, `Signed-off-by:`, `Reviewed-by:`, `Reported-by:`) before sending. Unescaped `<email@addr>` trailers cause "Bad Request: can't parse entities" 400 errors from the Telegram API.
- Use a `compose-notify` shell step that writes the rendered message into `.tgmsg`, then have the telegram plugin send `message_file: .tgmsg`. Do not interpolate `{{commit.message}}` directly into an `html`-formatted message body.
- Woodpecker eats `${VAR}` in command strings — always use `$VAR` (or `$$VAR` to escape) in pipeline command shells.
## Telegram Authentication
- `POST /api/auth/telegram` must allow Telegram Mini App retries with the same signed `initData`; Telegram may reuse launch data across reloads, retries, and duplicate client calls.
- Do not add one-time replay rejection to first-class Telegram login. Use signature verification, `auth_date` freshness, bot rejection, blocked-link checks, and rate limiting for this login path.
- Keep replay/deduplication checks scoped to routes where the payload is actually a one-time operation, such as webhook update handling or explicit Mini App session creation.
- Preserve or add regression tests whenever Telegram auth behavior changes.
## Verification
- Run focused tests for the changed area and a typecheck/build when practical.
- If Redis, email, or other optional infrastructure is unavailable during tests, successful auth paths should fail open only where the production code already treats that dependency as non-critical, and the final response should mention any noisy but non-failing warnings.
- Before final response, report the important verification commands and whether they passed.