Merge branch 'main' of ssh://git.manko.yoga:222/nick/nick-doc

This commit is contained in:
moojttaba
2026-05-31 07:50:51 +03:30
299 changed files with 24741 additions and 1186 deletions

0
.noleak Normal file
View File

View File

@@ -0,0 +1,280 @@
# PRD: Request Network In-House Checkout
**Status:** Draft — updated after 2026-05-28 dev webhook probe
**Date:** 2026-05-27
**Related:** `01 - Architecture/Request Network Integration Constraints.md` §1; `PRD - Request Network Migration and Funds Management.md`
**Owner:** Backend payments + Frontend
---
## 1. Problem
Buyers paying through Request Network (RN) are redirected from Amanat to RN's hosted page at `pay.request.network/?token=…`. That page has three concrete problems:
1. **Rabby is not supported.** A meaningful portion of our user base pays from Rabby. The hosted page does not detect it (no EIP-6963 enumeration, hard-coded MetaMask/WalletConnect/Coinbase). For those users, checkout fails before a transaction is even attempted.
2. **The page is brand-divergent.** Buyers leave the Amanat domain mid-flow, which is a trust and conversion cost we can avoid.
3. **The page relies on RN's choice of infrastructure.** Observed in our own probe: their UI wraps the payment in Safe smart accounts + ERC-4337 + a Pimlico paymaster, hitting public BSC RPCs that already rate-limited us once ("RPC endpoint returned too many errors"). We have no control to fix that.
The core RN protocol is sound. The hosted UI on top of it is the problem.
### 2026-05-28 reality update
The first dev BSC probe did not fail because RN stayed silent. It failed because Amanat dropped the provider callback:
- Test transaction: `0x3a23febd9abd43d7e0851c1ea86c4ceaf08c11098852cb0425fa074e9c88350b`.
- On-chain result: successful BSC USDC transfer to Amanat's configured destination wallet.
- RN webhook result: RN called `POST /api/payment/request-network/webhook` on `dev.amn.gg` four times from `34.34.233.192`.
- Backend result: nginx/backend returned `404` because the handler did not correlate all Request Network identifiers stored on the `Payment` record.
- Frontend result: the callback page stayed on the processing state and later hit `429` from 3-second polling.
The pre-implementation gate therefore changes from "prove RN calls us" to "deploy and smoke-test the webhook correlation repair, callback polling repair, and transaction-safety gate before another paid probe".
## 2. Goal
Replace the redirect to RN's hosted page with an Amanat-rendered checkout that:
- Connects any EVM wallet (Rabby, MetaMask, OKX, Trust, WalletConnect — anything wagmi's `injected()` connector enumerates).
- Builds and submits the exact same on-chain calls RN's hosted page makes, so RN's existing webhook fires unchanged.
- Stays on `dev.amn.gg` (or prod equivalent) for the entire flow.
- Falls back gracefully to the hosted page for buyers who explicitly request it.
Settlement, webhook handling, and the backend `Payment` lifecycle do not change. Only the buyer-facing checkout surface changes.
Webhook delivery alone is not a sufficient trust signal. Amanat must run a **Transaction Safety Provider** gate before marking escrow funded. The initial provider checks: transaction hash present, configured confirmation depth, transfer recipient/token/amount match on-chain evidence, and future AML/sanctions provider approval.
## 3. Non-goals
- Per-seller multi-chain configuration (covered in `Request Network Integration Constraints.md` §2 — separate PRD).
- Per-`(buyer, merchant)` ephemeral wallets / wallet abstraction layer (§3 — separate PRD).
- Replacing RN entirely with our own chain watcher (§4 — depends on this PRD's outcome).
- Gasless / sponsored transactions for the buyer. Buyer pays own gas via their EOA. We can revisit paymaster integration in a follow-up.
## 4. Background: what RN's protocol actually requires
Cold-inspected from a real `$12` BSC payment on RN's hosted page:
| Item | Value |
|---|---|
| Proxy contract (BSC, and same address on most EVMs via deterministic CREATE2) | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` |
| Function | `transferFromWithReferenceAndFee(address token, address to, uint256 amount, bytes reference, uint256 feeAmount, address feeAddress)` |
| Selector | `0xc219a14d` |
| Emitted event | `TransferWithReferenceAndFee(token, to, amount, reference, feeAmount, feeAddress)` |
| `paymentReference` derivation | `last8Bytes(keccak256(lowercase(requestId + salt + destinationAddress)))` |
| Fee config seen on RN's UI | `feeAmount = 0`, `feeAddress = 0x…dEaD` |
RN's webhook listens for `TransferWithReferenceAndFee`. If we emit it ourselves with the correct `paymentReference`, the webhook fires. The hosted UI is a UI-only convenience — not a settlement requirement.
## 5. Proposed flow (buyer perspective)
1. Buyer clicks "Pay with Request Network" on the request page.
2. Frontend calls existing `POST /payment/request-network/intents` (unchanged endpoint, expanded response — see §6).
3. Frontend navigates to new in-house page: `/checkout/request-network/:paymentId`.
4. Page shows: amount, token, chain, recipient (truncated, copyable), countdown timer.
5. "Connect wallet" — wagmi `injected()` + `metaMask()` (already configured). Rabby works automatically via EIP-6963.
6. Once connected, page checks the buyer's wallet is on the correct chain. If not, prompts `switchChain` (wagmi).
7. Page also checks current allowance. If `allowance(buyer, RN_PROXY) >= amount`, skip approve.
8. Buyer signs **transaction 1: `USDC.approve(RN_PROXY, amount)`**. UI shows "Approving…" → "Approved".
9. Buyer signs **transaction 2: `RN_PROXY.transferFromWithReferenceAndFee(token, dest, amount, ref, 0, 0x…dEaD)`**. UI shows "Submitting payment…" → "Confirmed on-chain".
10. Page waits for backend `payment-confirmed` socket event (already emitted on RN webhook → `Payment.status='paid'`). Shows success state.
11. Redirect to existing payment-success route.
Escape hatches surfaced as secondary links on the page:
- **"Continue on Request Network's hosted page"** → falls through to `securePaymentUrl` (unchanged behavior).
- **"Copy payment details"** → exposes destination, amount, token, chain, paymentReference for manual entry into any wallet.
## 6. Backend changes
### 6.1 Intent response payload
Expand `PayInIntentResult` for RN. Today it returns `paymentUrl`, `paymentId`, `providerPaymentId`, `amount`, `config.networks`, `config.allowedTokens`, `raw`. Add:
```ts
inHouseCheckout: {
destination: '0x05E280d7f3cA954f37afA8B1E4d2a51D167c573e',
tokenAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
tokenSymbol: 'USDC',
decimals: 18, // BSC USDC quirk; varies per token+chain
chainId: 56,
proxyAddress: '0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9',
paymentReference: '0xa826e248f5de5463', // 8-byte hex, computed from RN's requestId + salt
feeAmount: '0',
feeAddress: '0x000000000000000000000000000000000000dEaD',
amountWei: '12000000000000000000', // pre-multiplied by decimals, frontend doesn't compute
}
```
### 6.2 Sources for each field
| Field | Source |
|---|---|
| `destination`, `tokenAddress`, `chainId` | Parse `REQUEST_NETWORK_MERCHANT_REFERENCE` env (`address@eip155:chainId#ref:tokenAddr`). Today done once at startup; extract into a helper. |
| `decimals` | Token registry lookup. Hardcode the few we care about (USDC/USDT × BSC/ETH/Arb/Polygon) in a constant map; fall back to on-chain `decimals()` if missing. |
| `proxyAddress` | Constant per chain. Use canonical `0x0DfbEe14…0aC9` for all EVMs unless RN deploys a non-canonical address (open question — see §10). |
| `paymentReference` | Computed from RN's `/v2/secure-payments` response. `salt` is in their `raw` payload. Use `keccak256` + slice-to-8-bytes. |
| `feeAmount`, `feeAddress` | Constants matching what RN's hosted UI submits. Verify via cold inspection on each new chain we enable. |
| `amountWei` | `BigInt(amount) * 10n ** BigInt(decimals)`. Done backend-side to avoid frontend rounding bugs. |
### 6.3 Files touched
- `backend/src/services/payment/requestNetwork/contract.ts` — extend `PayInIntentResult` (or a new `inHouseCheckout` sub-object).
- `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts` — populate the new fields.
- `backend/src/services/payment/requestNetwork/merchantReference.ts` (new) — parser for the `address@chain#ref:token` format. Currently this is implicit in env lookups; promote to a named helper.
- `backend/src/services/payment/requestNetwork/tokens.ts` (new) — `{ chainId, tokenAddr } → { symbol, decimals }` lookup.
- `backend/src/services/payment/requestNetwork/paymentReference.ts` (new) — `computePaymentReference(requestId, salt, destination)`.
- Unit tests for each new helper.
### 6.4 Webhook handler
The route remains `/api/payment/request-network/webhook`, but the handler must be hardened before more paid probes:
- Correlate against every RN identifier we persist: `providerPaymentId`, `metadata.requestNetworkRequestId`, `metadata.requestNetworkPaymentReference`, and nested `metadata.requestNetworkData` ids/references.
- Reject unsigned or test-header callbacks unless explicit test mode is enabled.
- Store raw webhook evidence on the `Payment` record for support/replay.
- Call the Transaction Safety Provider before marking `Payment.status='completed'` / `escrowState='funded'`.
- Return a successful pending response when safety is not yet satisfied, so the delivery is not lost but escrow is not credited.
## 7. Frontend changes
### 7.1 New page
`frontend/src/pages/checkout/request-network/[paymentId].tsx` (or whatever the router convention is).
State machine (one component, reducer or zustand):
```
idle
→ connecting (wallet not connected)
→ wrong-chain (connected, chain mismatch)
→ ready (correct chain, allowance unknown)
→ checking-allowance
→ needs-approve → approving → approve-confirming → ready-to-pay
→ ready-to-pay
→ paying → pay-confirming → confirmed
→ error (with retry)
→ expired (timer ran out)
```
### 7.2 Reusable parts from SHKeeper
`frontend/src/web3/components/manual-payment.tsx` already implements: address-with-QR, copy-to-clipboard, countdown timer, localStorage persistence per request, socket-driven status update. Reuse the layout chrome. The wallet-interaction half is new.
### 7.3 Wagmi calls
Two contracts. The ERC-20 ABI is already in viem/wagmi. RN proxy ABI is a single function — define it inline.
```ts
const RN_PROXY_ABI = [{
inputs: [
{ name: 'tokenAddress', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'paymentReference', type: 'bytes' },
{ name: 'feeAmount', type: 'uint256' },
{ name: 'feeAddress', type: 'address' },
],
name: 'transferFromWithReferenceAndFee',
outputs: [], stateMutability: 'nonpayable', type: 'function',
}] as const;
```
Hooks used:
- `useAccount`, `useChainId`, `useSwitchChain`
- `useReadContract` for `allowance`
- `useWriteContract` + `useWaitForTransactionReceipt` for approve and proxy call
### 7.4 Routing change
Today, `createRequestNetworkIntent` consumers do `window.location = response.paymentUrl`. Replace with `router.push('/checkout/request-network/' + response.paymentId)`. Keep `response.paymentUrl` available so the in-house page can render the escape-hatch link.
### 7.5 Files touched
- `frontend/src/pages/checkout/request-network/[paymentId].tsx` — new page.
- `frontend/src/web3/components/rn-in-house-checkout.tsx` — new component holding the state machine.
- `frontend/src/web3/contracts/rn-fee-proxy.ts` — ABI + address-per-chain constants.
- `frontend/src/sections/request/components/buyer-steps/…` — change the existing RN button handler from `window.location` to `router.push`.
- Tests/Storybook for the new component.
## 8. Acceptance criteria
1. A buyer using **Rabby** can complete an RN payment end-to-end on `dev.amn.gg` without leaving the domain.
2. The two on-chain transactions emit a `TransferWithReferenceAndFee` event with the same `paymentReference` RN's hosted page would have produced.
3. RN's existing webhook fires on the event and the `Payment` doc transitions to `completed` / `escrowState='funded'` after safety approval.
4. If buyer connects on the wrong chain, they see a one-click "Switch network" CTA.
5. If buyer already has sufficient allowance, the approve step is skipped.
6. If the buyer abandons the page, returning to it restores their state from localStorage and resumes from the correct step.
7. "Continue on Request Network's hosted page" link is visible on the in-house page and works exactly like today's redirect.
8. Page handles tx failure (user reject, insufficient gas, RPC error) with a clear retry path that does not corrupt the `Payment` doc.
9. Existing non-RN payment flows (SHKeeper, others) are untouched.
## 9. Out of scope (explicit non-decisions)
- Multi-chain at checkout (BSC + Arb + ETH together) — needs the per-seller `acceptedChains` config; separate PRD.
- WalletConnect integration — currently disabled in `web3/config.ts` for SSR reasons; not regressed but not added either.
- Migration plan to remove RN entirely — kept as `§4` open option in the architecture doc.
- Mobile Mini App rendering of the checkout (different surface — handled in Task 5.4).
## 10. Open questions for review
These are the items we should discuss with the second developer before starting implementation.
1. **Proxy address universality.** Is `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` the RN ERC20FeeProxy address on **every** chain we plan to support (BSC, Arb, ETH mainnet, Polygon, Base)? We confirmed BSC + Arbitrum from RN's payments-subgraph repo. The other chains need explicit confirmation — preferably an on-chain probe per chain.
2. **API pricing for hosted-UI vs API-only.** RN's pricing model may assume hosted-UI usage. If we use them as a notification primitive only, are we still in their pricing terms? Worth a direct question to RN account management.
3. **Approval UX.** RN's hosted UI uses `approve(spender, MAX_UINT256)` for gas efficiency. Some Amanat users have voiced concern in past audits about unbounded approvals. Options:
- Mirror RN: `MAX_UINT256` (best UX, repeat payments don't need re-approve).
- Exact-amount: `approve(spender, amount)`. Slightly worse UX (every payment requires an approve), better security posture.
- Recommend: exact-amount by default, opt-in "remember this approval" for power users. Discuss.
4. **Tokens with non-standard ERC-20 behavior.** USDT on Ethereum mainnet famously requires `approve(0)` before changing a non-zero allowance. We need to handle this if we add USDT-ETH later. Not relevant on day one (BSC config is USDC), but worth flagging now.
5. **Cancel and timeout semantics.** Today the `Payment` doc transitions on the webhook. If the buyer signs the approve but bails on the proxy call, the doc sits in `pending` forever. Options:
- 30-minute TTL on the `pending` doc (existing partial unique index would let a re-attempt succeed).
- "Cancel intent" button on the in-house page that calls a new backend endpoint to mark the doc `cancelled`.
- Both. Discuss.
6. **Manual reconciliation.** If a buyer pays the correct amount to the correct destination but bypasses the proxy (e.g., raw `transfer`), no event fires and no webhook arrives. They'll think they paid but our system won't know. Today RN's hosted UI prevents this; our in-house UI inherits the same risk only if we add a "raw transfer" escape hatch — which we won't. But: should we proactively detect orphan transfers (chain watcher) and surface them in support? Out of scope for v1.
7. **Feature flag and rollout.** Ship behind a flag and A/B against the current redirect for one week of dev usage? Or hard-cut once acceptance criteria pass? Recommend A/B for at least one full release cycle.
8. **Telemetry.** What events do we instrument so we can prove the in-house page outperforms the redirect on conversion? Suggested: `rn_checkout_opened`, `rn_wallet_connected`, `rn_approve_signed`, `rn_pay_signed`, `rn_pay_confirmed`, `rn_pay_failed{reason}`, `rn_escape_to_hosted_clicked`.
## 11. Risks
- **Webhook handling failure.** RN delivered the 2026-05-28 dev webhook, but Amanat returned `404`. A deployed correlation fix and smoke test are now the load-bearing gate before another paid probe.
- **Webhook durability.** The main app is too unstable to be the only callback landing zone. Put a Cloudflare Worker in front of Request Network webhooks to durably store raw delivery evidence, forward to the backend, and replay after outages.
- **False-positive payment credit.** A signed provider event must not be enough to fund escrow. Transaction Safety Provider gates completion on on-chain evidence, confirmation depth, transfer matching, and future AML/sanctions checks.
- **Chain-specific divergences.** RN may have deployed non-canonical proxy contracts on some chains. We mitigate by hard-coding per-chain proxy addresses and treating "unknown chain → fall back to hosted page" as the safe default.
- **Wallet support drift.** EIP-6963 is mature but not universal. We rely on wagmi's `injected()` connector enumeration; if a wallet doesn't implement EIP-6963, it falls into the generic `window.ethereum` slot. Acceptable.
- **Buyer signs approve, then we change ABI before they sign the proxy call.** Not really a risk if both txs are in the same session, but worth a smoke test on a long-lived session.
## 12. Implementation phases
Roughly two weeks of checkout work, now gated on confirmation reliability repair.
| Phase | Work | Gate |
|---|---|---|
| 0 — Probe | Fire one real dev BSC payment and inspect nginx/backend logs | Completed: RN webhook reached nginx/backend; app returned `404` |
| 0A — Confirmation repair | Deploy correlation fix, callback polling fix, signed-webhook smoke test, and Transaction Safety Provider | Unsigned/test callbacks rejected unless explicitly enabled; real webhook can find the `Payment` |
| 1 — Backend | Expand intent response with `inHouseCheckout` fields; add helpers and tests | Existing RN smoke test still passes; new unit tests pass |
| 2 — Frontend skeleton | New page + state machine + wallet-connect; no on-chain calls yet, mocked confirmations | Local clickthrough on `dev.amn.gg` with Rabby connected |
| 3 — On-chain wiring | Real `approve` + `transferFromWithReferenceAndFee` calls; allowance check; chain-switch | One real end-to-end payment on dev BSC, webhook fires, `Payment.status='completed'`, safety approved |
| 4 — Hardening | Error handling, timer, persistence, telemetry, escape-hatch link | Acceptance criteria 19 demonstrably pass |
| 5 — Durable ingress | Cloudflare Worker receives RN webhooks, stores delivery records, forwards to backend, supports replay | Backend outage no longer loses webhook evidence |
| 6 — Rollout | Behind feature flag, A/B in dev for one cycle, then prod | Conversion telemetry; no regressions in non-RN flows |
## 13. Durable webhook ingress roadmap
Add a Cloudflare Worker as the public Request Network webhook target:
1. Receive the raw RN webhook body, headers, request id, delivery id, source IP, and timestamp.
2. Verify the RN signature at the edge if raw-body secret handling is straightforward; otherwise store first and let the backend perform canonical verification.
3. Write an immutable delivery record to durable storage. Candidate stack: Cloudflare Queues for handoff plus D1/R2/KV for indexed replay metadata and raw payload retention.
4. Forward to the primary backend and optionally a secondary endpoint.
5. Return `2xx` only after durable enqueue/store succeeds.
6. Provide operator replay by delivery id, payment reference, request id, or time window.
The Worker is only ingress/evidence buffering. The backend remains the trust boundary for signature verification, idempotency, Transaction Safety Provider checks, ledger updates, and marketplace state transitions.
## 14. References
- Cold-inspected transaction (real `$12` BSC payment on RN hosted page): in conversation history, dated 2026-05-27.
- RN ERC20FeeProxy spec: <https://github.com/RequestNetwork/requestNetwork/blob/master/packages/advanced-logic/specs/payment-network-erc20-fee-proxy-contract-0.1.0.md>
- Arbitrum proxy deployment: <https://github.com/RequestNetwork/payments-subgraph/blob/main/subgraph.arbitrum-one.yaml>
- Architecture constraints doc: `01 - Architecture/Request Network Integration Constraints.md`
- Previous RN work: `PRD - Request Network Migration and Funds Management.md`

View File

@@ -326,6 +326,16 @@
"parentTaskId": 3, "parentTaskId": 3,
"parentId": "undefined", "parentId": "undefined",
"updatedAt": "2026-05-24T06:51:00.615Z" "updatedAt": "2026-05-24T06:51:00.615Z"
},
{
"id": 13,
"title": "Add durable RN webhook ingress and transaction safety",
"description": "Roadmap follow-up from the 2026-05-28 dev payment probe: Request Network delivered the webhook but Amanat returned 404. Add Cloudflare Worker durable webhook ingress with storage/replay and keep backend Transaction Safety Provider checks as the trust boundary before marking escrow funded.",
"details": "",
"status": "pending",
"dependencies": [],
"parentTaskId": 3,
"parentId": "undefined"
} }
], ],
"updatedAt": "2026-05-24T07:04:01.906Z" "updatedAt": "2026-05-24T07:04:01.906Z"
@@ -635,13 +645,134 @@
} }
], ],
"updatedAt": "2026-05-24T13:46:14.458Z" "updatedAt": "2026-05-24T13:46:14.458Z"
},
{
"id": "6",
"title": "Request Network in-house checkout (Rabby-supporting)",
"description": "Replace the redirect to pay.request.network with an Amanat-rendered checkout page that submits the same on-chain calls as RN's hosted UI, so RN's webhook fires unchanged but buyers stay on amn.gg and Rabby works.",
"details": "See PRD: nick-doc/.taskmaster/docs/prd-request-network-in-house-checkout.md (summary at nick-doc/PRD - Request Network In-House Checkout.md). Status: draft, pending review with second developer. Approach: replicate the two on-chain calls (approve + RN_FEE_PROXY.transferFromWithReferenceAndFee) using wagmi v2 with existing injected()/metaMask() connectors (Rabby works via EIP-6963). Hard-known: proxy 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9, selector 0xc219a14d, paymentRef = last8Bytes(keccak256(requestId+salt+dest)), feeAmount=0, feeAddress=0x...dEaD. Backend: extend POST /payment/request-network/intents response with inHouseCheckout object (destination, tokenAddress, decimals, chainId, proxyAddress, paymentReference, feeAmount, feeAddress, amountWei). Frontend: new page /checkout/request-network/:paymentId with state machine reusing manual-payment.tsx layout chrome, hosted-page link kept as escape hatch. Implementation gated on a $0.50 cold probe on dev BSC to confirm RN's webhook fires for an externally-built tx. Out of scope: per-seller multi-chain config (§2), ephemeral wallets (§3), full RN removal (§4), gasless. Open questions in PRD §10.",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [
{
"id": 1,
"title": "Deploy confirmation repair before next paid probe",
"description": "2026-05-28 dev BSC transaction succeeded and RN delivered four webhooks, but Amanat returned 404 due Request Network reference-correlation mismatch. Before another paid payment test, deploy the backend correlation fix, callback polling fix, signed-webhook smoke test, and Transaction Safety Provider gate; then repeat the probe and inspect safety decision state.",
"details": "",
"status": "done",
"dependencies": [],
"parentTaskId": 6,
"updatedAt": "2026-05-28T07:34:40.368Z",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-28T07:34:40.368Z"
},
{
"id": "7",
"title": "Per-(buyer, sellerOffer) ephemeral RN destination wallets",
"description": "Replace the single shared Amanat destination wallet with a per-(buyerId, sellerOfferId) HD-derived address sent to Request Network on intent creation, plus sweep-on-approval and an admin UI.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §1. Files: new backend/src/services/payment/wallets/derivedDestinations.ts (getDestinationFor(buyerId, sellerOfferId) → {address, derivationPath, chainId}); Payment schema add metadata.derivedDestination; requestNetworkPayInService.ts override destinationId before POST /v2/secure-payments (we confirmed RN accepts different destinations per intent); new sweep cron + admin manual-trigger endpoint gated on Transaction Safety Provider; admin UI at /dashboard/admin/derived-destinations with address, balance, last sweep tx (BscScan link), ownership status. Open questions to settle first: HD vs disposable EOAs vs smart-forwarder (recommended HD); sweep cadence (recommended immediate); granularity (recommended per-(buyer, seller), not per-payment); re-use vs rotate after sweep. KMS-rooted seed; backend never holds raw private keys; signing via KMS API (Task #11 Trezor flow is the longer-term replacement). Acceptance: two payments from one buyer to two sellers land on two different addresses; RN webhook fires for both; sweep is idempotent; master seed never leaves KMS.",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-28T11:51:34.115Z"
},
{
"id": "8",
"title": "Multichain RN proxy registry + USDC/USDT support",
"description": "Probe and persist RN ERC20FeeProxy addresses on BSC/Arb/ETH/Polygon/Base, add USDC + USDT token entries with correct decimals per chain, and surface an admin networks page. Include the USDT-mainnet approve(0) reset quirk in the frontend approve step.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §2. Tasks: new backend/scripts/probe-rn-chains.ts that walks each chain in supported-chains.json and verifies the canonical 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 proxy is the real RN proxy via a known view fn (CREATE2 is deterministic, but verify); promote backend/src/services/payment/requestNetwork/tokens.ts to load from JSON + admin override; add USDT entries on all 5 chains (BSC USDT 18-dec quirk, mainnet/Arb/Polygon/Base USDT 6-dec); buildInHouseCheckoutBlock returns reason='unsupported_chain:<id>' for unknowns; new admin route GET /api/admin/rn/networks + frontend page /dashboard/admin/networks rendering the registry with per-row 'probe again'. Frontend approve flow: if buyer is on Ethereum mainnet AND token is USDT AND current allowance > 0, do approve(spender, 0) first then approve(spender, amount). Acceptance: probe succeeds on at least BSC/Arb/Polygon/ETH/Base; one paid probe on BSC USDT end-to-end; mainnet USDT approve(0) reset works; admin page reflects registry. Dependencies: none — runs in parallel with #9. This is task #8 in the PRD.",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-29T08:21:05.470Z"
},
{
"id": "9",
"title": "Per-chain confirmation thresholds + admin UI",
"description": "Make TransactionSafetyProvider's confirmation threshold tunable at runtime per chain via admin UI, with an awaiting-confirmation payments view that shows live confirmations vs threshold.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §3. Today TRANSACTION_SAFETY_MIN_CONFIRMATIONS is a global env var, default 12, baked in until redeploy. Move to runtime config: new Setting docs keyed 'confirmation_threshold:<chainId>' or extend existing model; cache reads in transactionSafetyProvider.ts for 30s; GET/PATCH /api/admin/settings/confirmation-thresholds (auth: admin); new admin page /dashboard/admin/confirmation-thresholds (table: chain, current, recommended default, edit-in-place with confirm dialog, audit log of changes); new admin page /dashboard/admin/payments/awaiting-confirmation (payments where escrowState !== 'funded' AND metadata.transactionSafety.lastCheck.status === 'pending'; for each show tx hash linked to explorer, current confirmations via 12s poll on BSC, threshold, ETA). Acceptance: admin lowers BSC threshold from 12 to 3 on dev, next webhook honors new value within 30s; awaiting-confirmation table updates live; audit log records every change. Non-goals: per-asset, per-seller thresholds. Dependencies: none. This is task #9 in the PRD.",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-29T09:51:57.565Z"
},
{
"id": "10",
"title": "Optional AML screening on incoming payments (seller-paid)",
"description": "Turn the existing aml_screening placeholder in TransactionSafetyProvider into a real Chainalysis (or equivalent) Address Screening call that the seller opts into per-offer and pays the per-check cost for.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §4. Default provider recommendation: Chainalysis Address Screening (cheapest, simplest). Files: new backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl behind env TRANSACTION_SAFETY_AML_PROVIDER=chainalysis with API_KEY in KMS; transactionSafetyProvider's evaluateAmlPlaceholder() becomes real, persists raw provider response on Payment.metadata.amlResult; Offer schema add requireAmlCheck + amlBlockOnFailure booleans; offer-edit UI toggle 'Require AML on incoming payments ($X per payment, paid by you)'; admin global config UI for provider selection + API key rotation + per-chain enabled flag; cost accounting: deduct per-check cost from seller's escrow on completion as a separate ledger line item, surfaced on payment-details. Open questions before code: pick provider (Chainalysis vs TRM vs Elliptic — need 1-page comparison of cost/latency/coverage); failure mode (fail-closed only when seller opted in AND amlBlockOnFailure=true, else warn/log); cost batching cadence. Acceptance: seller toggles AML on an offer; incoming payment triggers a real Chainalysis call; sanctions verdict blocks the safety gate; clean verdict passes; seller's settled amount reduced by check cost; admin can rotate API key without redeploy; provider-down + amlBlockOnFailure=true keeps payment pending with provider_unavailable reason. Dependencies: none. This is task #10 in the PRD.",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-29T10:00:28.716Z"
},
{
"id": "11",
"title": "Trezor signing for admin actions (release/refund/sweep)",
"description": "Replace the hot-key admin signing flow with a WebUSB-based Trezor flow so the backend never holds a private key. All admin-side txes are built backend, signed via Trezor in the browser, broadcast from the browser.",
"details": "See PRD - Wallet, Multichain, Confirmations, AML, Trezor.md §5. Lib: @trezor/connect-web (WebUSB; Chromium-only — Firefox users need Trezor Bridge native helper). Files: new frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web; existing admin actions (release/refund/sweep when #7 lands) get a 'Sign with Trezor' button that flows: POST /api/admin/actions/build-tx → returns unsigned tx bytes → send to Trezor → sign → wagmi sendTransaction broadcasts → POST /api/admin/actions/confirm-tx with hash; admin settings page to register Trezor address(es) (backend rejects signatures from unauthorized devices); audit log on every Trezor-signed action; break-glass hot-key path requires explicit admin toggle, expires after 1h, fires Telegram alarm. Open questions: m-of-n multi-admin signing — default single-signer for v1; Trezor One vs Model T — lib abstracts; fallback when Trezor unavailable — break-glass with alarm. Acceptance: admin registers Trezor address; release flow uses Trezor end-to-end; backend rejects signatures from unregistered devices; audit log captures admin user + Trezor addr + tx hash + before/after escrow state; break-glass works and alarms. Non-goals: mobile Trezor flow, buyer-side Trezor (buyer uses wagmi injected). Dependencies: task #7 (ephemeral wallets) for the sweep step — but task #11 can ship the release/refund flows first. This is task #11 in the PRD.",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-29T10:50:02.957Z"
},
{
"id": "12",
"title": "Replace auth rate limiter with CAPTCHA (Cloudflare Turnstile or reCAPTCHA v3)",
"description": "The current authLimiter blocks all login attempts from an IP for 15 minutes after N failures. This creates terrible UX (legitimate users get locked out, especially during testing) and is bypassable via rotating IPs anyway. Replace with a progressive challenge: allow 3 attempts freely, then require CAPTCHA (Cloudflare Turnstile preferred — no user friction; reCAPTCHA v3 as fallback). Backend verifies the token server-side before proceeding with auth. Rate limiter can stay as a last-resort backstop but with a much higher threshold (e.g. 100 req/15 min).",
"details": "",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-29T11:23:30.368Z"
},
{
"id": "13",
"title": "AMN Pay Scanner — retire Request Network API (Go microservice)",
"description": "Build a standalone Go microservice (AMN Pay Scanner) that replaces the RN API: generates paymentReferences locally, scans ERC20FeeProxy eth_getLogs per chain, and delivers HMAC-signed webhooks to the backend on confirmation. Backend swaps provider from 'request.network' to 'amn.scanner' via a new adapter. Supports any destination address, enabling HD-derived addresses as real payment destinations.",
"details": "See PRD - Retire Request Network — In-House Payment Scanner.md. Service exposes: POST /intents, GET /intents/:id, GET /scanner/status, GET /health. Node.js backend adds amnPayAdapter.ts and POST /api/payment/amn-scanner/webhook receiver. Parallel-run with RN during drain period. Language: Go v1 (Rust rewrite if volume justifies).\n\nImplemented by Kimi 2026-05-29. Scanner repo: scanner@8fee27e. Backend: backend@cdc8df1. Frontend: frontend@a5dd48e. Still open: live e2e probe (manual ops step — deploy scanner + send real BSC TransferWithReferenceAndFee tx to verify event topic match + webhook delivery).",
"testStrategy": "1. POST /intents returns checkoutBlock within 300ms with no RN API call. 2. Scanner detects TransferWithReferenceAndFee on BSC within 2 poll cycles. 3. Payment marked confirmed after threshold blocks. 4. Scanner resumes from checkpoint after restart. 5. Webhook rejected on bad HMAC.",
"priority": "high",
"status": "done",
"dependencies": [
"8"
],
"subtasks": []
},
{
"id": "14",
"title": "Sweep service — PermitPull + GasTopUp (Kimi, backend@7688f57)",
"description": "Standalone sweep service with three signer modes: PermitPullSweepSigner (EIP-712 gasless permit for ETH/Arb/Polygon/Base), GasTopUpSweepSigner (BNB top-up for BSC), BuildOnlySweepSigner (fallback). Auto-selects by chainId and token. Currently uses SWEEP_MASTER_PRIVKEY hot key — Task #11 (Trezor) replaces this.",
"details": "Implemented by Kimi in backend@7688f57 (integrate-main-into-development). Files: src/services/payment/wallets/sweepService.ts, __tests__/sweep-service.test.ts. PERMIT_CAPABLE_TOKENS seeded from 2026-05-29 on-chain audit. 31/31 unit tests pass. Still open: on-chain integration tests (one per signer mode against testnet or Anvil fork). Env vars added: SWEEP_MASTER_PRIVKEY, SWEEP_GAS_MIN_BNB, SWEEP_GAS_TOP_UP_BNB.",
"testStrategy": "Unit: 31/31 pass (auto-selection, permit capability matrix, gas top-up logic). Integration (open): one live broadcast per signer mode on BSC testnet or local Anvil fork.",
"priority": "high",
"status": "done",
"dependencies": [],
"subtasks": [],
"updatedAt": "2026-05-29T11:56:24.674Z"
} }
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-05-24T13:46:14.458Z", "lastModified": "2026-05-29T11:56:24.675Z",
"taskCount": 5, "taskCount": 14,
"completedCount": 4, "completedCount": 11,
"tags": [ "tags": [
"master" "master"
] ]

View File

@@ -44,17 +44,17 @@ created: 2026-05-23
### Dispute ### Dispute
> [!info] Definition > [!info] Definition
> A formal complaint opened by either party when a deal goes wrong. Would create a three-way chat (buyer, seller, admin) and a `Dispute` document with a structured `timeline[]`, `evidence[]`, and `resolution`. Categories: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. Outcomes: `refund | replacement | compensation | warning_seller | ban_seller | no_action`. See `backend/src/models/Dispute.ts` *(planned, not yet implemented)*. > A formal complaint opened by either party when a deal goes wrong. Creates a three-way chat (buyer, seller, admin) and a `Dispute` document with a structured `timeline[]`, `evidence[]`, and `resolution`. Categories: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. Outcomes in the current model: `refund | replacement | compensation | warning_seller | ban_seller | no_action`. See `backend/src/models/Dispute.ts` and [[Dispute Flow]].
### Escrow ### Escrow
> [!info] Definition > [!info] Definition
> The custodial period during which buyer funds are held by the platform (SHKeeper or the smart contract layer) after payment but before release to the seller. Escrow guarantees the seller will be paid if they deliver, and guarantees the buyer can be refunded if they do not. The defining feature of Amn. > The custodial period during which buyer funds are held by platform-controlled custody infrastructure after payment but before release to the seller. The current primary path uses Request Network pay-in, per-payment derived destinations, transaction-safety checks, and an internal funds ledger. Future custody decentralization is tracked in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Idempotency ### Idempotency
> [!info] Definition > [!info] Definition
> The property that the same request (identified by an idempotency key) can safely be retried without performing the underlying operation more than once. Critical for payment webhooks — SHKeeper may deliver the same webhook several times if it does not receive a 200 quickly. Amn enforces idempotency in `PaymentCoordinator` and at the model level via unique constraints on transaction hashes. > The property that the same request (identified by an idempotency key) can safely be retried without performing the underlying operation more than once. Critical for payment webhooks and release/refund confirmations. Amn enforces idempotency in `PaymentCoordinator`, Request Network delivery handling, pending-intent indexes, and ledger idempotency keys.
### JWT (Access / Refresh) ### JWT (Access / Refresh)
@@ -89,12 +89,12 @@ created: 2026-05-23
### Pay-in ### Pay-in
> [!info] Definition > [!info] Definition
> Money flowing **into** escrow from the buyer. Recorded as `Payment.direction: "in"`. The buyer's choice of pay-in surface (SHKeeper invoice vs. Web3 wallet) is independent of how the payout will be sent. > Money flowing **into** escrow from the buyer. Recorded as `Payment.direction: "in"`. The current primary path is Request Network in-house checkout, with payment safety verified by webhook/reconciliation plus on-chain transaction checks.
### Pay-in Intent ### Pay-in Intent
> [!info] Definition > [!info] Definition
> The pre-authorisation record created when a buyer commits to paying but the funds have not yet arrived on-chain. Holds the chosen amount, currency, expected wallet address (SHKeeper) or counterparty (DePay), and an expiry. Becomes a confirmed `Payment` once the chain or webhook confirms settlement. > The pre-authorisation record created when a buyer commits to paying but the funds have not yet arrived on-chain. Holds amount, currency, Request Network IDs/payment reference, in-house checkout metadata, and expected destination. Becomes a confirmed `Payment` only after webhook/reconciliation and transaction-safety checks approve settlement.
### Payment ### Payment
@@ -109,7 +109,7 @@ created: 2026-05-23
### Payout ### Payout
> [!info] Definition > [!info] Definition
> Money flowing **out** of escrow to the seller's wallet. Recorded as `Payment.direction: "out"`. Triggered by admin action after delivery is confirmed; implemented via SHKeeper's payout API (`shkeeperPayoutService.ts`). > Money flowing **out** of escrow to the seller's wallet. Triggered by release/refund orchestration after delivery confirmation or dispute resolution. The roadmap moves execution authority to Safe multisig/hardware signers before any custom smart-contract escrow pilot.
### Points ### Points
@@ -174,7 +174,7 @@ created: 2026-05-23
### SHKeeper ### SHKeeper
> [!info] Definition > [!info] Definition
> A self-hosted crypto payment processor used as Amn's primary custodial pay-in / payout rail. Issues a fresh wallet address per invoice, watches the chain for incoming USDT, and emits a signed webhook on settlement. Lives at `https://pay.amn.gg` per `backend/TODO.md`. Integration code under `backend/src/services/payment/shkeeper/`. > A self-hosted crypto payment processor used by older Amanat payment designs. Its docs remain for migration and historical context, but the current backend payment tree has moved to Request Network as the primary provider.
### Socket Room ### Socket Room
@@ -194,12 +194,12 @@ created: 2026-05-23
### USDT / USDC ### USDT / USDC
> [!info] Definition > [!info] Definition
> The two stablecoins Amn supports out of the box for pay-in and payout. USDT is the default for SHKeeper invoices; both are supported in offer pricing (`SellerOffer.price.currency` enum: `USD | EUR | IRR | USDT | USDC`). > The two stablecoins Amn supports out of the box for pay-in and payout. Request Network token registry work covers USDC/USDT across supported EVM chains; both are also supported in offer pricing (`SellerOffer.price.currency` enum: `USD | EUR | IRR | USDT | USDC`).
### Webhook ### Webhook
> [!info] Definition > [!info] Definition
> An inbound HTTP POST from an external service notifying Amn of an event. SHKeeper webhooks (`/api/payment/shkeeper/webhook`) are the most important they confirm pay-ins. All webhooks are HMAC-signed; verification uses `SHKEEPER_WEBHOOK_SECRET`. Failed verifications are dropped. > An inbound HTTP POST from an external service notifying Amn of an event. The primary payment webhook is Request Network at `/api/payment/request-network/webhook`, signed with `x-request-network-signature`. Roadmap work puts durable ingress/replay in front of the backend while keeping backend signature verification and transaction-safety checks as the trust boundary.
### WalletConnect ### WalletConnect

View File

@@ -11,7 +11,7 @@ created: 2026-05-23
> - **Passkeys hardened** — challenge consumption is now single-use with immediate deletion, 5-minute expiry, and replay-attack protection. > - **Passkeys hardened** — challenge consumption is now single-use with immediate deletion, 5-minute expiry, and replay-attack protection.
> - **Web3 verification real** — `BSCTransactionVerifier` performs on-chain `eth_getTransactionReceipt` validation with confirmation counting. > - **Web3 verification real** — `BSCTransactionVerifier` performs on-chain `eth_getTransactionReceipt` validation with confirmation counting.
> - **Socket.IO auth enforced** — all socket connections require a valid JWT; room joins enforce strict ownership/participation checks. > - **Socket.IO auth enforced** — all socket connections require a valid JWT; room joins enforce strict ownership/participation checks.
> - **Dispute holds** documented as planned but not yet implemented; the `Dispute` model, service layer, and API routes do not exist in the current backend. > - **Dispute holds** now exist in the backend through the dispute/release-hold service; remaining work is canonical state-machine alignment and stronger release/refund policy enforcement.
> - **Data model docs aligned** with actual Mongoose schemas (Payment provider/escrowState enums, User model omissions documented). > - **Data model docs aligned** with actual Mongoose schemas (Payment provider/escrowState enums, User model omissions documented).
# Introduction # Introduction
@@ -34,7 +34,7 @@ Traditional marketplaces tend to live at one of two extremes:
1. **Fully custodial platforms** (Amazon, eBay, Fiverr) take a large cut, dictate every term of the transaction, and freeze funds on a whim. They work, but they are expensive and opaque. 1. **Fully custodial platforms** (Amazon, eBay, Fiverr) take a large cut, dictate every term of the transaction, and freeze funds on a whim. They work, but they are expensive and opaque.
2. **Free-form P2P channels** (Telegram groups, Discord servers, direct DMs) charge nothing but offer no protection at all. The first scam empties the wallet and there is no recourse. 2. **Free-form P2P channels** (Telegram groups, Discord servers, direct DMs) charge nothing but offer no protection at all. The first scam empties the wallet and there is no recourse.
Amn sits between the two. It charges a thin escrow margin, holds funds for only as long as it takes to confirm delivery, and supports both fiat-style stablecoin escrow (via [[SHKeeper]]) and direct on-chain settlement (via [[DePay]] and the user's own wallet) — meaning the buyer can keep custody of their crypto until the literal moment of release. Amn sits between the two. It charges a thin escrow margin, holds funds for only as long as it takes to confirm delivery, and now routes primary stablecoin pay-in through Request Network with an Amanat-rendered wallet checkout. The buyer keeps custody of their crypto until they sign the on-chain payment, while the platform keeps settlement, safety checks, and dispute resolution in one auditable flow.
> [!tip] Why "crypto-native"? > [!tip] Why "crypto-native"?
> The escrow rails are built around stablecoins (USDT/USDC) on EVM chains rather than card networks. That means no chargebacks, no 3-day settlement, no geographic restrictions — and a transparent, auditable transaction trail for every step of the deal. See [[Tech Stack]] for the full Web3 surface. > The escrow rails are built around stablecoins (USDT/USDC) on EVM chains rather than card networks. That means no chargebacks, no 3-day settlement, no geographic restrictions — and a transparent, auditable transaction trail for every step of the deal. See [[Tech Stack]] for the full Web3 surface.
@@ -56,7 +56,7 @@ Beyond the four roles, two ambient audiences read the platform:
A handful of design choices set Amn apart from generic marketplace software: A handful of design choices set Amn apart from generic marketplace software:
1. **Dual payment rails.** Every order can be paid through SHKeeper (a self-hosted crypto payment processor that issues a fresh wallet per invoice) *or* through a Web3 wallet connect flow (DePay + Wagmi/Viem + MetaMask). The buyer picks; the escrow logic is identical downstream. See [[Payments Overview]]. 1. **Request Network in-house checkout.** Every order can be paid through an Amanat-rendered Web3 checkout that builds Request Network-compatible transactions directly in the buyer's wallet. The hosted Request Network page remains a fallback, while the app keeps Rabby/MetaMask UX, chain choice, transaction safety checks, and escrow state in-house.
2. **Request-first marketplace.** Most platforms list *products*. Amn lists *needs*. Buyers describe what they want and let the market come to them — closer to a reverse auction than a catalogue. The unidirectional flow eliminates the "thousand-listings-with-no-stock" problem. 2. **Request-first marketplace.** Most platforms list *products*. Amn lists *needs*. Buyers describe what they want and let the market come to them — closer to a reverse auction than a catalogue. The unidirectional flow eliminates the "thousand-listings-with-no-stock" problem.
3. **Request Templates.** Power buyers (and admins) can publish reusable purchase request templates that act like express checkouts — a buyer clicks "I want this" and the order is opened pre-filled. Templates are the bridge between Amn and conventional ecommerce. 3. **Request Templates.** Power buyers (and admins) can publish reusable purchase request templates that act like express checkouts — a buyer clicks "I want this" and the order is opened pre-filled. Templates are the bridge between Amn and conventional ecommerce.
4. **First-class i18n with RTL.** The frontend ships with six locales out of the box (English, French, Vietnamese, Chinese, Arabic, Persian) and full right-to-left support — Persian is the default fallback. See `frontend/src/locales/locales-config.ts:36`. 4. **First-class i18n with RTL.** The frontend ships with six locales out of the box (English, French, Vietnamese, Chinese, Arabic, Persian) and full right-to-left support — Persian is the default fallback. See `frontend/src/locales/locales-config.ts:36`.
@@ -78,4 +78,4 @@ A handful of design choices set Amn apart from generic marketplace software:
## Project status at a glance ## Project status at a glance
Amn is at version **2.6.x** across both repositories, on the `development` branch, and tagged "production-ready with minor enhancements" by the project leads. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on UX polish, admin analytics, and a more granular permissions matrix — see `backend/TODO.md` and `frontend/VERSION_0_PREPARATION_TODO.md` for the rolling task list, and [[Roadmap]] (forthcoming) for the strategic view. Amn is at version **2.6.x** across both repositories, on the `development` branch, and tagged "production-ready with minor enhancements" by the project leads. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on Request Network hardening, durable webhook ingress, derived-destination custody, admin signing, and a more granular permissions matrix. The custody/smart-contract strategy lives in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].

View File

@@ -7,9 +7,9 @@ created: 2026-05-23
# Roles & Personas # Roles & Personas
> [!info] Where roles live in code > [!info] Where roles live in code
> The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller"`. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards. > The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller" | "resolver"`. The `resolver` role was added to the backend in commit `fce8a19` and is now a first-class enum value in `User.ts`, `UserRole` enum in `shared/types/index.ts`, and the dispute routes. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards.
Amn has four user personas. Three are first-class roles in the data model; the fourth (Support) is a special-cased admin with reduced privileges. Amn has five user personas. Four are first-class roles in the data model; the fifth (Support) is a special-cased admin with reduced privileges.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -18,11 +18,13 @@ flowchart LR
Seller["Seller<br/>(Owner)"] Seller["Seller<br/>(Owner)"]
Support["Support<br/>(admin variant)"] Support["Support<br/>(admin variant)"]
Admin["Admin"] Admin["Admin"]
Resolver["Resolver<br/>(dispute specialist)"]
Visitor -->|signs up| Buyer Visitor -->|signs up| Buyer
Buyer -->|requests seller mode<br/>+ admin approval| Seller Buyer -->|requests seller mode<br/>+ admin approval| Seller
Buyer & Seller -->|opens ticket| Support Buyer & Seller -->|opens ticket| Support
Support -->|escalates| Admin Support -->|escalates| Admin
Admin -->|assigns role| Resolver
``` ```
--- ---
@@ -37,7 +39,7 @@ flowchart LR
- **Browse and search** the public marketplace and request templates. - **Browse and search** the public marketplace and request templates.
- **Create a [[Purchase Request]]** describing what they want — product type (physical / digital / service / consultation), budget, urgency, delivery info, attachments. See `backend/src/models/PurchaseRequest.ts`. - **Create a [[Purchase Request]]** describing what they want — product type (physical / digital / service / consultation), budget, urgency, delivery info, attachments. See `backend/src/models/PurchaseRequest.ts`.
- **Review incoming [[Seller Offer]]s**, negotiate over chat, accept the best one. - **Review incoming [[Seller Offer]]s**, negotiate over chat, accept the best one.
- **Pay** via [[SHKeeper]] (custodial crypto invoice) or Web3 wallet ([[DePay]] + MetaMask through Wagmi). - **Pay** via the Request Network in-house checkout, using a supported EVM wallet through Wagmi/WalletConnect and the platform's payment request metadata.
- **Track the order** through `processing → delivery → delivered → confirming → completed` states. - **Track the order** through `processing → delivery → delivered → confirming → completed` states.
- **Confirm receipt** (or let the SLA auto-confirm), leave a review, accrue points. - **Confirm receipt** (or let the SLA auto-confirm), leave a review, accrue points.
- **Open a [[Dispute]]** if delivery never lands, item is wrong, or quality is poor. - **Open a [[Dispute]]** if delivery never lands, item is wrong, or quality is poor.
@@ -82,11 +84,11 @@ The buyer dashboard lives under `/dashboard` (`frontend/src/app/dashboard/`). No
- **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`. - **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`.
- **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`). - **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`).
- **Submit offers** with price, currency (USDT default, USDC, USD, EUR, IRR supported), delivery time, optional attachments and notes. - **Submit offers** with price in **USDT** (the only supported currency for the escrow MVP — USD/EUR/IRR removed in commit 3aaa2fe), delivery time, optional attachments and notes.
- **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted. - **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted.
- **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables. - **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables.
- **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt. - **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt.
- **Receive payout** automatically via SHKeeper to the configured wallet once the order is finalised (admin-triggered batch or per-order based on shop policy). - **Receive payout** to the configured wallet after ledger-gated release. Today this is an admin/custody-signer operation; the target path is Safe/hardware-backed approvals as described in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
- **Manage [[Request Templates]]** scoped to their shop — publish "off-the-shelf" offerings buyers can purchase in one click. - **Manage [[Request Templates]]** scoped to their shop — publish "off-the-shelf" offerings buyers can purchase in one click.
- **Engage with reviews and disputes**: respond to reviews, contest disputes, provide evidence. - **Engage with reviews and disputes**: respond to reviews, contest disputes, provide evidence.
@@ -110,6 +112,7 @@ Seller dashboard reuses the same `/dashboard` shell with extra modules:
- `/dashboard/request-template` — create / edit shop-scoped templates - `/dashboard/request-template` — create / edit shop-scoped templates
- `/dashboard/payment` — receivables, payout history, pending releases - `/dashboard/payment` — receivables, payout history, pending releases
- `/dashboard/disputes` — disputes where the seller is the respondent - `/dashboard/disputes` — disputes where the seller is the respondent
- `/dashboard/seller/marketplace/offers`**Offer Management** (tabbed view of all own offers filtered by status: pending / accepted / rejected / withdrawn; inline withdraw action; commit 9cf1686)
> [!tip] See also > [!tip] See also
> [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine. > [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine.
@@ -125,12 +128,12 @@ Seller dashboard reuses the same `/dashboard` shell with extra modules:
- **Moderate users**: suspend / unsuspend accounts (`User.status: "active" | "suspended" | "deleted"`, see `backend/src/models/User.ts`), promote buyers to sellers, ban repeat offenders. - **Moderate users**: suspend / unsuspend accounts (`User.status: "active" | "suspended" | "deleted"`, see `backend/src/models/User.ts`), promote buyers to sellers, ban repeat offenders.
- **Moderate marketplace content**: categories (`Category` model), request templates (the canonical platform-wide ones), blog posts. - **Moderate marketplace content**: categories (`Category` model), request templates (the canonical platform-wide ones), blog posts.
- **Resolve disputes**: get assigned to disputes, drive them to resolution, choose an outcome (`refund | replacement | compensation | warning_seller | ban_seller | no_action`). See `backend/src/services/dispute/DisputeService.ts` *(planned, not yet implemented)*. - **Resolve disputes**: get assigned to disputes, drive them to resolution, choose an outcome (`refund | replacement | compensation | warning_seller | ban_seller | no_action`). See `backend/src/services/dispute/DisputeService.ts` and [[Dispute Flow]].
- **Operate payments**: trigger payouts, fetch on-chain transactions, manually confirm stuck payments (the manual transaction-hash flow described in `backend/TODO.md`), audit the SHKeeper webhook history (`services/payment/shkeeper/webhookStats.ts`). - **Operate payments**: trigger ledger-gated releases/refunds, review Request Network webhooks, inspect derived destination wallets, fetch on-chain transactions, and manually confirm stuck payments only after Transaction Safety Provider checks.
- **Configure the platform**: levels (`LevelConfig`), points multipliers, blog seed content, default templates. - **Configure the platform**: levels (`LevelConfig`), points multipliers, blog seed content, default templates.
- **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`). - **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`).
- **Author blog posts** via the TipTap rich-text editor. - **Author blog posts** via the TipTap rich-text editor.
- **Monitor health**: SHKeeper status (background health monitor in `app.ts:433`), Redis, MongoDB. - **Monitor health**: Request Network webhook/reconciliation status, ledger enforcement, custody signer/Safe readiness, Redis, and MongoDB.
### Key permissions ### Key permissions
@@ -149,7 +152,7 @@ Admins see the buyer/seller surfaces plus dedicated admin modules (typically und
- User management (search, suspend, role change) - User management (search, suspend, role change)
- Dispute queue with assignment and resolution - Dispute queue with assignment and resolution
- Payment console (manual confirmation, payout dispatch, webhook log) - Payment console (manual confirmation, release/refund dispatch, Request Network webhook and ledger log)
- Category and template management - Category and template management
- Blog editor (publish / unpublish / featured) - Blog editor (publish / unpublish / featured)
- Platform analytics (TODO — see `backend/TODO.md`) - Platform analytics (TODO — see `backend/TODO.md`)
@@ -193,6 +196,30 @@ Support sees a stripped-down admin view focused on the inbox:
--- ---
## Resolver
> [!example] Who they are
> A platform-employed dispute resolver (`role: "resolver"`). Added to the backend as a first-class role in commit `fce8a19`. Resolvers have targeted authority to mediate and formally resolve disputes — they can assign disputes, update status, issue final resolutions (including `ban_seller` or `refund`), view statistics, and bypass chat membership checks (commit `766a9a2`) to read/send in any chat.
### Primary workflows
- **Review dispute details**: read buyer and seller evidence, chat history, delivery confirmations.
- **Communicate** directly through any chat — bypasses participant membership guard.
- **Assign, update status, and resolve disputes** with the same actions as admins (`refund | replacement | compensation | warning_seller | ban_seller | no_action`).
- **Monitor dispute health** via `GET /api/disputes/statistics`.
### Key permissions
- Full triage on disputes: `POST /:id/assign`, `PATCH /:id/status`, `POST /:id/resolve`, `GET /statistics`.
- Read and write messages in any chat (bypass membership check in `ChatService`).
- Read any dispute and its evidence.
- **Cannot**: change roles, issue payouts, suspend users, delete content, access non-dispute admin endpoints.
> [!note] Implementation
> The `resolver` role was added as a first-class backend enum in commit `fce8a19` (`User.ts`, `UserRole` in `shared/types/index.ts`, dispute routes). Chat bypass was added in commit `766a9a2`.
---
## Cross-cutting concerns ## Cross-cutting concerns
### Role transitions ### Role transitions
@@ -202,6 +229,7 @@ Support sees a stripped-down admin view focused on the inbox:
| Anonymous | Buyer | Self-service signup | `User` created | | Anonymous | Buyer | Self-service signup | `User` created |
| Buyer | Seller | Application → admin approval | `User.role` change | | Buyer | Seller | Application → admin approval | `User.role` change |
| Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual | | Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual |
| Buyer / Seller | Resolver | Admin role assignment | `User.role` change |
| Admin | Support | Permission profile applied at middleware | Role stays `admin` | | Admin | Support | Permission profile applied at middleware | Role stays `admin` |
### Permission model ### Permission model

View File

@@ -16,7 +16,7 @@ Amn is a **two-repo system**:
- **Frontend** (`/Users/mojtabaheidari/code/frontend`) — a Next.js 16 App Router application that serves the marketplace UI, the admin dashboard, the public blog, and the user-facing Web3 wallet flow. - **Frontend** (`/Users/mojtabaheidari/code/frontend`) — a Next.js 16 App Router application that serves the marketplace UI, the admin dashboard, the public blog, and the user-facing Web3 wallet flow.
- **Backend** (`/Users/mojtabaheidari/code/backend`) — an Express 5 + TypeScript API server that owns all business logic, persists to MongoDB, caches in Redis, and brokers all external integrations. - **Backend** (`/Users/mojtabaheidari/code/backend`) — an Express 5 + TypeScript API server that owns all business logic, persists to MongoDB, caches in Redis, and brokers all external integrations.
The two repos are deployable independently. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to MongoDB, Redis, SHKeeper, or OpenAI — every external interaction is mediated by the backend so that secrets stay on the server. The two repos are deployable independently. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to MongoDB, Redis, Request Network API keys, OpenAI, or admin custody secrets -- every sensitive external interaction is mediated by the backend so that secrets stay on the server.
## System map ## System map
@@ -41,7 +41,7 @@ flowchart TB
Auth["Auth service<br/>JWT + Passkey + Google + Telegram"] Auth["Auth service<br/>JWT + Passkey + Google + Telegram"]
Market["Marketplace service<br/>Requests, Offers, Templates"] Market["Marketplace service<br/>Requests, Offers, Templates"]
ChatSvc["Chat service"] ChatSvc["Chat service"]
PaySvc["Payment service<br/>SHKeeper + Request Network + ledger"] PaySvc["Payment service<br/>Request Network + ledger + custody controls"]
TelegramSvc["Telegram service<br/>bot + Mini App + notifications"] TelegramSvc["Telegram service<br/>bot + Mini App + notifications"]
Disp["Dispute service"] Disp["Dispute service"]
Points["Points / Referrals"] Points["Points / Referrals"]
@@ -58,8 +58,6 @@ flowchart TB
end end
subgraph External["External services"] subgraph External["External services"]
SHK["SHKeeper<br/>crypto invoicing"]
DePay["DePay widget"]
Chain["EVM chains<br/>BSC / ETH / Polygon"] Chain["EVM chains<br/>BSC / ETH / Polygon"]
SMTP["SMTP<br/>(nodemailer)"] SMTP["SMTP<br/>(nodemailer)"]
OpenAI["OpenAI API"] OpenAI["OpenAI API"]
@@ -68,6 +66,7 @@ flowchart TB
Alchemy["Alchemy RPC"] Alchemy["Alchemy RPC"]
TelegramAPI["Telegram Bot API<br/>+ Mini App"] TelegramAPI["Telegram Bot API<br/>+ Mini App"]
ReqNet["Request Network<br/>pay-in / webhooks"] ReqNet["Request Network<br/>pay-in / webhooks"]
CFWorker["Durable webhook ingress<br/>(roadmap)"]
end end
Browser --> SSR Browser --> SSR
@@ -88,13 +87,10 @@ flowchart TB
Auth & PaySvc & Notif --> RedisDB Auth & PaySvc & Notif --> RedisDB
Files --> Disk Files --> Disk
PaySvc <--> SHK
SHK -.webhook.-> PaySvc
PaySvc <--> ReqNet PaySvc <--> ReqNet
ReqNet -.webhook.-> PaySvc ReqNet -.webhook.-> CFWorker
CFWorker -.forward/replay.-> PaySvc
PaySvc --> Chain PaySvc --> Chain
Wagmi --> DePay
DePay --> Chain
PaySvc -.tx fetch.-> Alchemy PaySvc -.tx fetch.-> Alchemy
TelegramSvc <--> TelegramAPI TelegramSvc <--> TelegramAPI
@@ -130,16 +126,17 @@ The heart of the platform. Three first-class models drive it:
Services live in `backend/src/services/marketplace/` and are exposed through `/api/marketplace/*`. The frontend uses a mix of React Query (`@tanstack/react-query`) and SWR for data fetching, with mutations gated through the actions layer in `frontend/src/actions/`. Services live in `backend/src/services/marketplace/` and are exposed through `/api/marketplace/*`. The frontend uses a mix of React Query (`@tanstack/react-query`) and SWR for data fetching, with mutations gated through the actions layer in `frontend/src/actions/`.
### Payments — [[Payments Overview]] / [[SHKeeper Integration]] ### Payments -- Request Network, Ledger, And Custody Controls
Payments are where Amn is most distinctive. The backend supports **four payment surfaces** routed through a common `Payment` model (`backend/src/models/Payment.ts`) via a provider-neutral adapter layer (`backend/src/services/payment/adapters/`): Payments are where Amn is most distinctive. The live backend has converged on **Request Network** as the primary provider through a common `Payment` model (`backend/src/models/Payment.ts`) and provider-neutral adapter layer (`backend/src/services/payment/adapters/`):
- **SHKeeper** `/api/payment/shkeeper`. Issues a fresh wallet address per invoice, polls / webhooks for payment confirmation, and runs through `PaymentCoordinator` to avoid race conditions. Health is monitored in the background (`shkeeperHealthCheck.ts`). - **Request Network pay-in** -- `/api/payment/request-network`. Creates requests, exposes the Amanat in-house checkout block, and receives signed webhooks (`x-request-network-signature`). Pay-in service: `requestNetworkPayInService.ts`; reconciliation: `requestNetworkReconciliationService.ts`.
- **Request Network** — `/api/payment/request-network`. Creates on-chain payment requests via the Request Network protocol, generates Secure Payment Page URLs for the buyer, and receives real-time payment status via signed webhooks (`x-request-network-signature`). Pay-in service: `requestNetworkPayInService.ts`; reconciliation: `requestNetworkReconciliationService.ts`. - **In-house wallet checkout** -- buyer signs the RN-compatible `approve` + `transferFromWithReferenceAndFee` flow from their own wallet, so Rabby/MetaMask wallet UX stays inside Amanat.
- **Decentralized (Wagmi + DePay)** `/api/payment/decentralized`. The user signs and sends the transfer from their own wallet; the backend verifies on-chain via `blockchainTxFetcher.ts` and the Alchemy SDK. - **Derived destination wallets** -- `/api/payment/derived-destinations` admin endpoints manage per-`(buyer, sellerOffer, chainId)` receiving addresses, sweep status, and config health.
- **Payout** `/api/payment/shkeeper/payout`. Admin-triggered release of escrow funds to the seller's wallet once delivery is confirmed. - **Funds ledger** -- `backend/src/services/payment/ledger/` tracks payment detection, holds, releases, refunds, fees, and adjustments independently of provider metadata.
- **Release/refund orchestration** -- `/api/payment/:id/(release|refund)` builds instructions; `/confirm` records confirmed transaction hashes. Optional Trezor enforcement gates confirmation when `TREZOR_SAFEKEEPING_REQUIRED=true`.
All surfaces converge on the same `Payment` record (with `direction: 'in' | 'out' | 'refund'`) and share the internal **funds ledger** (`backend/src/services/payment/ledger/`) which tracks available / held / releasable amounts independently of the provider. **Pending payments are auto-cleaned** by a background timer started in `app.ts`. Historical SHKeeper and DePay docs remain in the vault for migration context, but the current backend tree no longer has `backend/src/services/payment/shkeeper/`. The current strategic path is in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Real-time chat — [[Chat System]] ### Real-time chat — [[Chat System]]
@@ -164,9 +161,10 @@ Push and SMS are tracked as **planned** in `backend/TODO.md`.
### Disputes — [[Dispute System]] ### Disputes — [[Dispute System]]
When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend would create a **three-way chat** between buyer, seller, and admin, open a `Dispute` document with a structured `timeline[]` and `evidence[]`, and assign the dispute to an admin via `assignAdmin()`. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` and is recorded on the dispute itself. When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend creates a **three-way chat** between buyer, seller, and admin, opens a `Dispute` document with a structured `timeline[]` and `evidence[]`, and can assign the dispute to an admin via `assignAdmin()`. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` in the current Mongoose model.
> [!warning] Not implemented
> `backend/src/services/dispute/DisputeService.ts` does not exist as of 2026-05-24. > [!note] State alignment gap
> The dispute module exists now, but its model still uses the legacy `pending | in_progress | resolved | ...` enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future enum and financial side effects.
### Points & referrals — [[Points System]] ### Points & referrals — [[Points System]]
@@ -191,9 +189,10 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
- locks used by `PaymentCoordinator` to serialise status transitions - locks used by `PaymentCoordinator` to serialise status transitions
- rate-limit counters (currently disabled in code but plumbed in) - rate-limit counters (currently disabled in code but plumbed in)
**Background workers** run inside the Express process for now no separate worker tier. Notable timers: **Background workers** run inside the Express process for now -- no separate worker tier. Notable timers:
- `startPendingPaymentsCleanup()` — sweeps stale unpaid invoices - `startPendingPaymentsCleanup()` — sweeps stale unpaid invoices
- `startShkeeperHealthMonitor()` — pings the SHKeeper instance and surfaces alerts - optional derived-destination sweep cron — sweeps eligible per-payment receiving addresses when configured
- Request Network reconciliation — enabled via provider config when the rollout requires fallback status repair
- Auto-seed logic on startup (gated by `NODE_ENV` and `AUTO_SEED_ON_START`) - Auto-seed logic on startup (gated by `NODE_ENV` and `AUTO_SEED_ON_START`)
## Request lifecycle (the happy path) ## Request lifecycle (the happy path)
@@ -203,9 +202,10 @@ OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current
> 2. Buyer creates a [[Purchase Request]] → `POST /api/marketplace/requests`. The request lands in `pending`/`active`. Sellers in the matching category receive a Socket.IO notification. > 2. Buyer creates a [[Purchase Request]] → `POST /api/marketplace/requests`. The request lands in `pending`/`active`. Sellers in the matching category receive a Socket.IO notification.
> 3. Seller views the request, opens [[Seller Offer]] modal, submits price + delivery time → `POST /api/marketplace/offers`. Buyer sees the offer arrive live. > 3. Seller views the request, opens [[Seller Offer]] modal, submits price + delivery time → `POST /api/marketplace/offers`. Buyer sees the offer arrive live.
> 4. Buyer accepts an offer → request moves to `payment`. UI opens the payment selector. > 4. Buyer accepts an offer → request moves to `payment`. UI opens the payment selector.
> 5. Buyer picks **SHKeeper** backend creates a SHKeeper invoice, returns a wallet address + QR code. Buyer pays. SHKeeper webhook hits `/api/payment/shkeeper/webhook`; `PaymentCoordinator` flips `Payment.status = paid` and `PurchaseRequest.status = processing`. > 5. Buyer picks **Request Network** -> backend creates a Payment and RN intent, returns an in-house checkout block, and the buyer signs the on-chain payment from their wallet.
> 6. Seller ships. Buyer confirms delivery (or it auto-confirms after the SLA window). Admin triggers (or schedules) a **payout** → SHKeeper releases USDT to the seller's wallet. > 6. Request Network webhook/reconciliation plus the Transaction Safety Provider confirm tx hash, recipient, token, amount, and confirmations before the backend marks escrow funded.
> 7. Both parties leave reviews. Points are awarded. The deal is closed. > 7. Seller ships. Buyer confirms delivery (or an admin resolves the order/dispute). Admin/custody owners execute release/refund through the release/refund instruction flow.
> 8. Both parties leave reviews. Points are awarded. The deal is closed.
> >
> If the buyer disputes the delivery, jump to step 7 of the [[Dispute Flow]] instead. > If the buyer disputes the delivery, jump to step 7 of the [[Dispute Flow]] instead.

View File

@@ -117,7 +117,7 @@ The frontend is a Next.js 16 App Router application written in TypeScript. The b
## Backend stack ## Backend stack
The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backed by MongoDB (Mongoose), Redis, and Socket.IO. It owns all integrations with SHKeeper, the EVM chains, OpenAI, Google OAuth, and SMTP. The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backed by MongoDB (Mongoose), Redis, and Socket.IO. It owns all integrations with Request Network, EVM chains, OpenAI, Google OAuth, Telegram, SMTP, and custody/signing controls.
### Core runtime & framework ### Core runtime & framework
@@ -135,7 +135,7 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline | | sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline |
| dotenv | ^17.2.0 | Env var loader | Bootstrap | | dotenv | ^17.2.0 | Env var loader | Bootstrap |
| uuid | ^11.1.0 | ID generation | Tokens, ephemeral IDs | | uuid | ^11.1.0 | ID generation | Tokens, ephemeral IDs |
| axios | ^1.11.0 | Outbound HTTP (SHKeeper, blockchain) | Integration calls | | axios | ^1.11.0 | Outbound HTTP (Request Network, blockchain/RPC helpers) | Integration calls |
| @babel/runtime | ^7.27.6 | Babel runtime helpers | Compiled output | | @babel/runtime | ^7.27.6 | Babel runtime helpers | Compiled output |
> [!warning] React in backend dependencies > [!warning] React in backend dependencies
@@ -210,9 +210,12 @@ The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backe
| Service | Purpose | Touchpoint in code | | Service | Purpose | Touchpoint in code |
|---|---|---| |---|---|---|
| **SHKeeper** | Self-hosted crypto payment processor — issues wallets, watches for incoming USDT, pays out | `backend/src/services/payment/shkeeper/` | | **Request Network** | On-chain payment request protocol -- creates payment requests, supports in-house checkout metadata, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters |
| **Request Network** | On-chain payment request protocol — creates invoices, generates Secure Payment Pages, signs webhooks | `backend/src/services/payment/requestNetwork/` + adapters | | **Derived destination wallets** | Per-`(buyer, sellerOffer, chainId)` receiving addresses plus sweep orchestration | `backend/src/services/payment/wallets/` |
| **DePay** | Drop-in Web3 widget for wallet-to-wallet payment | `@depay/widgets` on frontend | | **Transaction Safety Provider** | Confirms tx hash, recipient, token, amount, confirmation depth, and future AML result before escrow credit | `backend/src/services/payment/safety/` |
| **Trezor / future Safe multisig** | Hardware-backed admin signing today; Safe multisig target in custody roadmap | `backend/src/services/trezor/`, [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] |
| **SHKeeper** | Historical payment rail retained in documentation for migration context | legacy docs only |
| **DePay** | Historical/drop-in Web3 widget docs retained for context | frontend historical docs |
| **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` | | **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` |
| **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` | | **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` |
| **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) | | **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) |

View File

@@ -44,7 +44,8 @@ backend/src/
│ │ ├── migration/ # Legacy data backfill utilities │ │ ├── migration/ # Legacy data backfill utilities
│ │ ├── observability/ # Logging and incident controls │ │ ├── observability/ # Logging and incident controls
│ │ ├── requestNetwork/ # Request Network pay-in, routes, webhook signature │ │ ├── requestNetwork/ # Request Network pay-in, routes, webhook signature
│ │ ── shkeeper/ # SHKeeper API, webhook, payout │ │ ── safety/ # Transaction Safety Provider + confirmation thresholds
│ │ └── wallets/ # Derived destination wallets + sweep orchestration
│ ├── points/ # Loyalty points, levels, redemption │ ├── points/ # Loyalty points, levels, redemption
│ ├── redis/ # Redis client, cache helpers │ ├── redis/ # Redis client, cache helpers
│ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications │ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications
@@ -125,17 +126,19 @@ The full route table mounted by `app.ts`:
| `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list | | `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list |
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile | | `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile |
| `/api/payment` | `services/payment/paymentControllerRoutes.ts` + `paymentRoutes.ts` | JWT | Payment CRUD, health, export | | `/api/payment` | `services/payment/paymentControllerRoutes.ts` + `paymentRoutes.ts` | JWT | Payment CRUD, health, export |
| `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Web3 save, verify, receiver | | `/api/payment/decentralized` | `services/payment/decentralizedPaymentRoutes.ts` | mixed | Legacy/manual Web3 save, verify, receiver |
| `/api/payment/shkeeper` | `services/payment/shkeeper/shkeeperRoutes.ts` | mixed | Intents, webhook, release, refund, config | | `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | mixed + HMAC sig on webhook | Request Network pay-in creation, in-house checkout rehydrate, webhooks |
| `/api/payment/shkeeper/payout` | `services/payment/shkeeper/shkeeperPayoutRoutes.ts` | JWT (seller/admin) | Withdraw to wallet | | `/api/payment/derived-destinations` | `services/payment/wallets/derivedDestinationRoutes.ts` | JWT (admin) | Derived address list, sweeps, cron, config health |
| `/api/payment/request-network` | `services/payment/requestNetwork/requestNetworkRoutes.ts` | HMAC sig | Request Network pay-in creation, Secure Payment Page, webhooks | | `/api/admin/rn/networks` | `services/payment/requestNetwork/networkRegistryRoutes.ts` | JWT (admin) | Supported RN chain/token registry |
| `/api/admin/settings/confirmation-thresholds` | `services/admin/confirmationThresholdRoutes.ts` | JWT (admin) | Runtime min-confirmation thresholds |
| `/api/admin/payments/awaiting-confirmation` | `services/admin/awaitingConfirmationRoutes.ts` | JWT (admin) | Payments blocked on safety confirmations |
| `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook | | `/api/telegram` | `services/telegram/telegramRoutes.ts` | mixed (some JWT, webhook uses secret-token) | Mini App verify/session, identity link/unlink, bot webhook |
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages | | `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
| `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read | | `/api/notification` | `services/notification/notificationRoutes.ts` + `notificationControllerRouter` | JWT | List, mark read |
| `/api/dispute` | `services/dispute/disputeRoutes.ts` | JWT | **Not implemented** — planned | | `/api/disputes` | `routes/disputeRoutes.ts` + `services/dispute/disputeRoutes.ts` | JWT | Dispute CRUD plus release-hold helpers |
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | **Not implemented** — planned | | `/api/blog` | `services/blog/blogRoutes.ts` | mixed | Public reads, admin writes |
| `/api/admin` | `services/admin/adminRoutes.ts` | JWT (admin) | **Not implemented** — planned | | `/api/admin/cleanup` | `services/admin/dataCleanupRoutes.ts` | JWT (admin) | Data cleanup operations |
| `/api/points` | `services/points/pointsRoutes.ts` | JWT | **Not implemented** — planned | | `/api/points` | `services/points/pointsRoutes.ts` | JWT | Points, levels, referrals |
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers | | `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers |
| `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload | | `/api/files` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
| `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch | | `/api/email` | `services/email/emailRoutes.ts` | JWT | Email dispatch |
@@ -253,9 +256,12 @@ Full table in [[Environment Variables]]. Critical ones:
| `JWT_EXPIRES_IN` | `7d` | | | `JWT_EXPIRES_IN` | `7d` | |
| `REFRESH_TOKEN_EXPIRES_IN` | `30d` | | | `REFRESH_TOKEN_EXPIRES_IN` | `30d` | |
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin | | `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
| `SHKEEPER_API_URL` | `https://pay.amn.gg` | | | `REQUEST_NETWORK_API_BASE_URL` | `https://api.request.network` | Request Network API |
| `SHKEEPER_API_KEY` | required | | | `REQUEST_NETWORK_API_KEY` | required | Request Network API credential |
| `SHKEEPER_WEBHOOK_SECRET` | required | HMAC key | | `REQUEST_NETWORK_WEBHOOK_SECRET` | required | Webhook HMAC key |
| `PAYMENT_LEDGER_ENFORCEMENT` | `false` | Target `true` before launch-scale releases |
| `TRANSACTION_SAFETY_*` | required for payments | Confirmation, transfer-match, and AML controls |
| `DERIVED_DESTINATION_SWEEP_SIGNER` | `build-only` | Target hardware/Safe-backed signer |
| `SMTP_*` | required | Nodemailer | | `SMTP_*` | required | Nodemailer |
| `OPENAI_API_KEY` | required | | | `OPENAI_API_KEY` | required | |
@@ -279,7 +285,7 @@ Redis client (in `src/services/redis/`) provides:
The codebase has no dedicated queue runner — scheduled / async work is triggered inline from request handlers and uses `setTimeout` / `setInterval` patterns where needed (e.g., delayed retries). Consider introducing Bull / BullMQ if you grow: The codebase has no dedicated queue runner — scheduled / async work is triggered inline from request handlers and uses `setTimeout` / `setInterval` patterns where needed (e.g., delayed retries). Consider introducing Bull / BullMQ if you grow:
- Payment status reconciliation (polling SHKeeper for stragglers) - Request Network webhook replay/reconciliation and derived-destination balance checks
- Notification email digests - Notification email digests
- Auto-release escrow timers - Auto-release escrow timers
- Token / refresh-token cleanup - Token / refresh-token cleanup
@@ -295,7 +301,10 @@ Jest test suites in `backend/__tests__/`:
| `models.test.ts` | Schema validation, virtuals, hooks | | `models.test.ts` | Schema validation, virtuals, hooks |
| `payment-services.test.ts` | Payment orchestration logic | | `payment-services.test.ts` | Payment orchestration logic |
| `complete-backend.test.ts` | Cross-service integration | | `complete-backend.test.ts` | Cross-service integration |
| `shkeeper-backend.test.ts` | SHKeeper service + webhook | | `request-network-webhook.test.ts` | Request Network webhook signature and processing |
| `request-network-adapter.test.ts` | Request Network payment adapter |
| `payment-ledger.service.test.ts` | Ledger append/reconciliation behavior |
| `payment-release-refund-orchestration.test.ts` | Release/refund instruction orchestration |
Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`, `npm run test:payment`, etc. when iterating on a slice. Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`, `npm run test:payment`, etc. when iterating on a slice.
@@ -310,7 +319,8 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
| `src/shared/utils/response-handler.ts` | Standard response shape | | `src/shared/utils/response-handler.ts` | Standard response shape |
| `src/shared/middleware/auth.ts` | JWT verify + RBAC | | `src/shared/middleware/auth.ts` | JWT verify + RBAC |
| `src/infrastructure/socket/socketService.ts` | All socket plumbing | | `src/infrastructure/socket/socketService.ts` | All socket plumbing |
| `src/services/payment/shkeeper/shkeeperWebhook.ts` | Webhook signature scheme | | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` | Request Network checkout and webhook route |
| `src/services/payment/ledger/fundsLedgerService.ts` | Immutable payment ledger writes |
| `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine | | `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine |
| `src/services/auth/authService.ts` | Auth flows, lockout, hashing | | `src/services/auth/authService.ts` | Auth flows, lockout, hashing |
| `src/models/User.ts` | Central entity with role/preferences | | `src/models/User.ts` | Central entity with role/preferences |
@@ -325,4 +335,4 @@ Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`,
- [[Real-time Layer]] — Socket.IO room model - [[Real-time Layer]] — Socket.IO room model
- [[Security Architecture]] — JWT, passkeys, webhook HMAC - [[Security Architecture]] — JWT, passkeys, webhook HMAC
- [[Data Model Overview]] — entity-relationship map - [[Data Model Overview]] — entity-relationship map
- [[Authentication Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]] - [[Authentication Flow]] · [[Escrow Flow]] · [[Dispute Flow]]

View File

@@ -0,0 +1,212 @@
# Database Strategy — Mongo vs Postgres Assessment
**Status:** Living assessment. Not a decision yet. Written 2026-05-28.
**Owner:** nick + claude
**Decision deadline:** Open. Re-evaluate when one of the trigger conditions below fires.
---
## TL;DR
Amanat runs on MongoDB (primary store) + Redis (cache/sessions/rate limits). For an escrow product that moves money, Postgres would be the structurally better fit — FK constraints, ACID across rows, mature audit/reporting tooling. But a full migration today is a **36 month, single-engineer-equivalent project with high schedule risk** and zero user-visible value during the cutover.
**Current recommendation:** Don't migrate. Pay down the specific weaknesses Mongo creates (cross-collection consistency, audit trails, FK-shaped bugs) with targeted in-place hardening. Revisit the decision when one of the trigger conditions below fires.
---
## What we run today
| Store | Use | Notes |
|---|---|---|
| MongoDB (Mongoose 8.x) | Primary store — all domain data | 22 models, ~454 query call sites across 171 backend TS files |
| Redis | Sessions, cache, rate limits (paymentLimiter etc.) | Not in scope for any migration. Keep as-is either way. |
### Mongoose models (22)
Ranked by how naturally they map to a relational schema:
| Tier | Models | Relational fit |
|---|---|---|
| **Core financial** | `Payment`, `FundsLedgerEntry`, `PurchaseRequest`, `DerivedDestination`, `Dispute` | Strong. These are where FK constraints + ACID earn their keep. The orphan-payment deletion bug we hit on 2026-05-28 (`provider:` filter missing) lives here — an FK would have prevented it structurally. |
| **Marketplace** | `SellerOffer`, `RequestTemplate`, `Category`, `Address`, `Review` | Strong. Already relational in shape. |
| **Identity** | `User`, `TelegramLink`, `TelegramSession`, `TempVerification`, `TrezorAccount` | Strong. Clean 1-to-many. |
| **Document-shaped** | `Chat`, `Notification`, `BlogPost`, `PointTransaction`, `LevelConfig`, `ShopSettings` | Weak. Chat especially — message arrays prefer either Mongo or Postgres JSONB. |
### Mongo-specific patterns we lean on
These are the patterns that get expensive to migrate:
- **Atomic upsert counters** — `Counter.findByIdAndUpdate({_id:'derived_destination_index'}, {$inc:{seq:1}}, {new:true, upsert:true})` in `derivedDestinations.ts`. Postgres equivalent is a `SERIAL` column or `nextval('seq')`, trivial — but every existing call site has to change.
- **Embedded `metadata` blobs** — `Payment.metadata.requestNetworkData`, `.derivedDestination`, `.transactionSafety`. Used heavily for RN raw payloads and per-payment overrides. Two migration paths in Postgres: JSONB column (cheap, loses indexed query-ability) or normalized side tables (lots of work, lots of joins).
- **Single-document atomicity assumption** — `grep -rE 'startSession|withTransaction'` finds **1 file** in the codebase using Mongo transactions. The remaining ~454 query sites implicitly rely on single-document atomicity. Going relational forces explicit transaction demarcation everywhere money moves; this is where post-migration bugs hide.
- **Aggregation pipelines** — 11 files use `.aggregate()`. Each is a custom rewrite to SQL.
---
## Cost of a full migration
One-engineer-equivalent, full-time, not parallel with feature work:
| Phase | Scope | Estimate |
|---|---|---|
| Schema design + ERD | 22 models → relational schema, decide JSONB vs normalized for each `metadata` field | 12 weeks |
| ORM swap (Prisma/Drizzle/TypeORM) | Rewrite 22 models, 454 query sites. ~80% mechanical, ~20% (aggregations, atomic upserts) need genuine rethinking | 610 weeks |
| Data backfill scripts | Mongo → Postgres ETL per collection. ObjectId → uuid/int FK resolution, embedded subdoc unrolling | 23 weeks |
| Cutover infra | Dual-write window, shadow reads, rollback plan, point-in-time backups | 12 weeks |
| Test fix-up | 36 backend test files mock/seed Mongo; rewrite harness, fixtures, in-memory DB | 23 weeks |
| Stabilization | Production incidents you didn't predict; the long tail | 24 weeks |
| **Total** | | **1424 weeks (3.56 months)** |
### Multipliers specific to this codebase
- Only 1 file uses Mongo transactions today → most boundaries are implicit. Going relational means *finding* and explicitly wrapping every multi-row money operation. High bug yield.
- Heavy `metadata` blob usage → either lose query-ability (JSONB) or pay normalization cost (side tables + joins everywhere).
- Multiple agents (nick + claude + kimi + moojttaba) commit weekly. A 4-month migration branch will rot constantly; rebasing it against a fast-moving main is a tax on every other feature.
- 36 test files all assume Mongo. Either keep both DBs in CI during transition, or rewrite the whole test harness up front.
---
## What we'd actually gain
Honest accounting:
| Win | Real value |
|---|---|
| FK constraints | Would have caught the 2026-05-28 orphan-payment bug (Payment cleanup with missing `provider:` filter). Will catch similar bugs in the future. |
| Multi-row ACID | Real value for escrow release + dispute resolution + payment-to-request creation. Today these rely on app-level invariants. |
| Audit / financial reporting | SQL is much friendlier for accountants, auditors, and ad-hoc analytical queries. |
| Mature tooling | pg_dump, point-in-time recovery, logical replication, Metabase/Superset integration. |
| Hiring | More backend engineers know SQL well than Mongo well. |
| Non-win (claimed but not real) | Why it doesn't materialize |
|---|---|
| "Better performance" | Mongo handles this app's load fine; we're nowhere near needing it to scale further. |
| "Better schemas" | Mongoose already enforces schemas at the app layer. The structural integrity gain is FKs, not types. |
| "Fewer bugs" | Most bugs we've hit (`rn_webhook_event_field`, `backend_rate_limits`, `woodpecker_silent_build_fail`, telegram parse_mode) are application logic, not DB choice. Postgres wouldn't have caught any of them. |
---
## The structurally better path: targeted hardening (~2 weeks)
Get most of the relational wins without the migration:
1. **Append-only ledger as source of truth.** Promote `FundsLedgerEntry` (or a new collection) to the authoritative record of every money movement. Strict invariants enforced in a single service. Becomes the audit log accountants and disputes consume.
2. **Explicit transaction boundaries.** Identify the ~5 places where multi-collection atomicity actually matters: Payment + PurchaseRequest creation, escrow release, dispute resolution, sweep + DerivedDestination update, refund. Wrap each in `mongoose.startSession() + session.withTransaction(...)`. This requires Mongo to be a replica set in prod (which it already is for our deployment).
3. **App-layer FK enforcement.** Mongoose `pre('save')` and `pre('deleteOne')` hooks that verify referenced documents exist before mutating. Catches the orphan-deletion class of bug. Cheap.
4. **Cleanup-query lint.** Codify the [[feedback-payment-cleanup-provider-filter]] rule: any `Payment.find()/.deleteMany()/.updateMany()` over the payments collection without a `provider:` filter is a bug. Custom ESLint rule or just a grep in CI.
Estimated cost: ~2 weeks. Catches the bugs that actually hurt. Leaves the migration option open.
---
## Partial-migration option: dual-DB for financial models only
A narrower question worth its own analysis: *what if we keep Mongo for the bulk of the app but move the financial/ledger operations to Postgres just to get ACID where money is involved?*
### Reference-surface in the current backend
| Model | Files referencing it |
|---|---|
| `Payment` | 33 |
| `PurchaseRequest` | 25 |
| `FundsLedgerEntry` | 4 |
| `DerivedDestination` | 4 |
| `Dispute` | 2 |
That gives three natural scoping tiers, each with very different cost.
### Option 1 — Ledger only (~34 weeks) — **recommended dual-DB shape**
Move just `FundsLedgerEntry` to Postgres. Keep everything else on Mongo. The ledger becomes the append-only authoritative record of every money movement, written through a single `LedgerService`.
| Phase | Work | Estimate |
|---|---|---|
| Postgres infra | docker-compose, dev seed, prod provisioning, backups, PITR | 34 days |
| Schema + Drizzle setup | One table + indexes, migrations | 2 days |
| Service boundary | `LedgerService` is the only writer; everywhere else reads | 34 days |
| Rewrite the 4 call sites | Mechanical | 2 days |
| Outbox pattern | Mongo write → outbox row → worker drains into Postgres. Survives crashes between the two writes. | 45 days |
| Reconciliation job | Nightly diff between ledger sum and Mongo-derived balances; alerts on drift | 23 days |
| Tests | Harness for both stores, ~10 new tests | 45 days |
| **Total** | | **34 weeks** |
**What you get:** Audit-grade money trail, ACID guarantee on the ledger itself, SQL-driven reporting for finance/regulators. No FK constraints across stores (does NOT solve the FK-shaped bug class — Mongo entities still can't reference Postgres rows with integrity), but the *financial record* is bulletproof.
**Risk:** The outbox is the load-bearing piece. If Mongo writes succeed and the worker crashes before the outbox drains, the ledger is briefly behind. Reconciliation closes the gap within 24h. Acceptable for typical regulatory regimes; not for high-frequency real-time settlement.
**Reusable foundation:** The outbox + reconciliation pattern built here is the template if you later expand to Option 2. None of the work is wasted.
### Option 2 — Ledger + Payment + Dispute (~1014 weeks)
Move `FundsLedgerEntry` + `Payment` + `Dispute` to Postgres. Keep `PurchaseRequest`, `User`, marketplace data in Mongo.
The hard part is not the 33 Payment refs — it's that **Payment refers to User, SellerOffer, PurchaseRequest, all of which live in Mongo**. Every cross-store join becomes an app-layer lookup. Queries like "find all Payments for users created last week" need a two-stage fetch.
| Phase | Work | Estimate |
|---|---|---|
| Everything from Option 1 | | 3 weeks |
| Payment + Dispute schema design | Including JSONB-vs-normalized for `Payment.metadata.requestNetworkData`, `.derivedDestination`, `.transactionSafety` | 12 weeks |
| Rewrite 33 + 2 = 35 call sites | Mix of mechanical + `populate('userId')` → manual lookup conversions | 34 weeks |
| Cross-store query helpers | Layer that fetches Payment from PG and enriches with User from Mongo. Pagination becomes painful. | 12 weeks |
| Dual-store transactional discipline | Payment update + PurchaseRequest update needs outbox + saga | 2 weeks |
| Tests rewrite | 36 test files, most touch Payment | 2 weeks |
| Stabilization | Cross-store bugs you didn't predict | 12 weeks |
| **Total** | | **1014 weeks** |
**What you get:** ACID across the entire payment lifecycle. But you've introduced a permanent cross-store consistency problem and queries got more complex everywhere.
### Option 3 — All five financial models (~1620 weeks)
Move all of `FundsLedgerEntry` + `Payment` + `PurchaseRequest` + `Dispute` + `DerivedDestination`. At this point you're approaching the full-migration cost (1424 weeks) without the full-migration cleanliness — you still own a cross-store boundary, just relocated to the User/marketplace edge.
**Skip this option.** If you're going this far, commit to the full migration plan in the section above instead of leaving an awkward two-store seam through the middle of the domain.
### Recommendation among dual-DB options
**Option 1 (ledger only, 34 weeks).** Smallest blast radius, cleanest service boundary, 80% of the auditor/regulator/finance-team value. Postgres becomes the source of truth for "did money move," not for "what's the order status." Revisit Option 2 only if a specific compliance ask or repeated cross-Payment consistency bugs force it.
**Avoid Option 2** unless there's a concrete forcing function. The permanent cross-store query pain is real and rarely worth it for the marginal ACID gain over Option 1 + good service discipline.
### How dual-DB Option 1 differs from "stay on Mongo + targeted hardening"
The 2-week in-place hardening above (append-only ledger collection, `withTransaction` on the 5 money-paths, `pre('save')` FK hooks, cleanup-query lint) gets you a *Mongo-native* version of most of Option 1's wins. The reasons to do Option 1 anyway:
- **Regulator/auditor specifically wants SQL** for ledger queries.
- **Finance team wants Metabase/Superset/BigQuery sync** with relational primitives, not Mongo aggregations.
- **A future financial product** (settlement netting, on-chain accounting export, multi-currency reconciliation) is on the roadmap and would be substantially easier in Postgres.
If none of those apply yet, the 2-week targeted hardening is still the right first step. Option 1 builds on top of it cleanly.
---
## When to revisit (trigger conditions)
Pull this doc out and re-evaluate when **any** of these fires:
1. **Compliance / audit requirement** — a regulator, payment partner, or auditor demands a relational ledger we can't easily produce from Mongo.
2. **Schema-flexibility cost has gone to zero** — feature velocity is no longer dominated by changing the shape of `Payment.metadata`, `RequestTemplate`, `PurchaseRequest`. If the schema has stabilized, the migration's main friction (rewriting too many evolving entities) is gone.
3. **The bug pattern has repeated** — we hit ≥3 incidents shaped like "missing referential integrity" or "no cross-collection transaction" within 6 months. Then the targeted hardening above wasn't enough and migration starts paying for itself.
4. **A green-field rewrite is happening anyway** — e.g. a major v2 architecture refactor, microservice split, or rewrite of the payments subsystem. Combine the migration with that work; don't do it standalone.
5. **Reporting needs blow up** — finance/ops team wants live SQL-driven dashboards and our Mongo aggregation pipelines + Metabase plugins can't keep up.
If none of the above fires, **stay on Mongo**.
---
## If we ever do migrate — order of operations
For when the trigger condition fires. Don't do it standalone — pair it with another large refactor.
1. Start with the **financial-tier models only** (Payment, FundsLedgerEntry, PurchaseRequest, DerivedDestination, Dispute). These are 5 of 22 models. Dual-store: Postgres for these, Mongo for the rest, with a sync layer or service-per-store boundary.
2. Validate for 3+ months on dev + prod-shadow before any cutover.
3. Migrate the marketplace + identity tiers next (10 more models). Document-shaped models (Chat, Notification, etc.) probably never need to migrate — they're happier in Mongo or as Postgres JSONB.
4. Use Drizzle or Prisma. Prefer Drizzle if you want migrations-as-code and don't want a heavy runtime; Prisma if the team prefers a higher-level abstraction.
5. **Don't** dual-write the same record. Pick one source of truth per model and don't compromise on it.
---
## Related
- [[feedback-payment-cleanup-provider-filter]] — the bug that prompted this discussion (Payment cleanup missing `provider:` filter destroyed multi-seller cart records).
- `PRD - Wallet, Multichain, Confirmations, AML, Trezor.md` — Task #7 (derived destinations) is the most Mongo-shaped feature we've shipped recently; reference for how atomic upserts and embedded metadata are used.
- `01 - Architecture/Request Network In-House Checkout.md` — RN integration relies heavily on `Payment.metadata.requestNetworkData` blob storage.

View File

@@ -211,7 +211,7 @@ const config = createConfig({
}); });
``` ```
Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The DePay widget (`@depay/widgets`) is loaded for the assisted-pay flow. Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The current checkout target is the Request Network in-house flow; the DePay widget package remains legacy/frontier context and should not be treated as the primary path.
--- ---

View File

@@ -190,7 +190,7 @@ See [[Monitoring]] for the full table of metrics & recommended alerts.
| Browser → Backend | 5001 | HTTP + WS | via Nginx `/api`, `/socket.io` | | Browser → Backend | 5001 | HTTP + WS | via Nginx `/api`, `/socket.io` |
| Backend → MongoDB | 27017 | TCP | Docker network | | Backend → MongoDB | 27017 | TCP | Docker network |
| Backend → Redis | 6379 | TCP | Docker network | | Backend → Redis | 6379 | TCP | Docker network |
| Backend → SHKeeper | 443 | HTTPS | External | | Backend → Request Network API | 443 | HTTPS | External payment provider |
| Backend → SMTP | 587 | TLS | External | | Backend → SMTP | 587 | TLS | External |
| Backend → OpenAI | 443 | HTTPS | External | | Backend → OpenAI | 443 | HTTPS | External |
| Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs | | Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs |

View File

@@ -215,6 +215,6 @@ Sticky sessions on the load balancer are also required so a given client always
## Related ## Related
- [[Backend Architecture]] · [[Frontend Architecture]] - [[Backend Architecture]] · [[Frontend Architecture]]
- [[Chat Flow]] · [[Notification Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]] - [[Chat Flow]] · [[Notification Flow]] · [[Escrow Flow]] · [[Dispute Flow]]
- [[Security Architecture]] — socket auth concerns - [[Security Architecture]] — socket auth concerns
- [[Socket Events]] — full event reference (developer-facing API doc) - [[Socket Events]] — full event reference (developer-facing API doc)

View File

@@ -1,22 +1,22 @@
# Request Network Integration — Constraints and Design Implications # Request Network Integration — Constraints and Design Implications
**Date:** 2026-05-27 **Date:** 2026-05-27
**Status:** Active concerns; mitigations partially designed, partially blocked on RN clarifications **Status:** Active concerns; 2026-05-28 probe confirmed RN webhook delivery but exposed Amanat confirmation handling gaps
**Owners:** Backend payments (Amanat), product **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. This document captures 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 ## 1. RN hosted UI does not support Rabby -- mitigated by Amanat in-house checkout
### Problem ### 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. 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) ### Mitigation (implemented core path)
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. Skip the RN-hosted UI. Amanat still calls `/v2/secure-payments`, stores the Request Network identifiers, and exposes an in-house checkout block. The frontend builds the same RN-compatible on-chain action from the buyer's wallet, so Rabby/MetaMask users stay inside the Amanat flow.
So the new flow becomes: So the new flow becomes:
@@ -24,18 +24,19 @@ So the new flow becomes:
2. **We render our own checkout screen** that: 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). - 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. - 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. - Builds the two RN-compatible transactions client-side: token `approve(proxy, amount)`, then `transferFromWithReferenceAndFee(...)` on RN's ERC20FeeProxy.
3. RN's webhook (`/v2/request/{id}`-style polling fallback) tells us when the payment lands. 3. RN's webhook tells us when the proxy event lands; Request Network search/status APIs remain the polling fallback.
### Why this is acceptable ### 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. - 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. - Buyer never sees a third-party brand mid-checkout, which is a UX win regardless of Rabby.
### Open ### Remaining work
- 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. - Keep the RN hosted URL exposed as an escape hatch.
- 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. - Continue hardening timer/persistence/telemetry around the in-house checkout.
- Treat durable webhook ingress as a production gate, because the main Express app should not be the only landing zone for callback evidence.
--- ---
@@ -51,7 +52,7 @@ The visible costs:
- Or seller gets less than they expected (worst — they'll dispute). - Or seller gets less than they expected (worst — they'll dispute).
- Plus settlement latency goes from seconds to minutes-hours depending on the bridge. - Plus settlement latency goes from seconds to minutes-hours depending on the bridge.
### Mitigation (designed) ### Mitigation (partially implemented)
Take the chain choice away from RN's UI and bring it into ours, gated by what the *seller* will accept. Take the chain choice away from RN's UI and bring it into ours, gated by what the *seller* will accept.
@@ -62,11 +63,11 @@ Two-step UX:
### Side benefit ### 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. This composes cleanly with #1 (own checkout screen): we already render the wallet picker, so seller-accepted chain selection can happen before wallet connection. The chain/token registry and admin networks page exist; seller-side accepted-chain policy remains a separate product/data-model task.
### Open ### 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. - We need a per-seller/per-offer config table for accepted chains. Today the global merchant reference is still the fallback, while derived destination work handles recipient variation.
- 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.** - 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.**
--- ---
@@ -83,7 +84,7 @@ Today the entire escrow stack receives funds into one (or a handful of) wallets
This is a show-stopper for going live at scale. Same class of issue we already considered around SHKeeper. 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) ### Mitigation (implemented core path; operational probe pending)
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: 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:
@@ -93,23 +94,23 @@ Per-`(buyer, merchant)`-pair ephemeral wallets. Each new escrow gets a freshly-g
### What this requires (architectural work) ### 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. 1. **Wallet abstraction layer** -- implemented in `backend/src/services/payment/wallets/derivedDestinations.ts` using xpub-only derivation.
2. **Address book / registry** — maps `(paymentId, chainId)` → derived address. Persists derivation path + sequence number so we can reproduce keys for sweeps later. 2. **Address book / registry** -- implemented in `DerivedDestination`, keyed by `(buyerId, sellerOfferId, chainId)`.
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. 3. **Sweep job** -- implemented with build-only/hot-key signer abstraction; production must keep build-only and move execution to Trezor/Safe.
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. 4. **Key custody policy** -- still the important missing operational layer. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### Critical open question ### 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: **Does RN support creating a secure-payment with a destination wallet we specify per request at production volume, rather than a static merchant reference?** The backend/frontend support the shape, but the live divergent-destination probe remains the operational proof point. If RN cannot support this reliably, fallback options are:
- Pre-register a large pool of addresses with RN and rotate through them, or - 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). - 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.** **Action: run the two-paid-intent divergent-destination probe and confirm with RN support whether this usage is supported on the same API key at expected volume.**
--- ---
## 4. RN reduced to a notification service viable, but not yet validated ## 4. RN reduced to a notification service -- viable, partially validated
### Problem statement ### Problem statement
@@ -131,15 +132,34 @@ Which is a *notification* primitive, not a payment platform. We'd be paying for
- We're outsourcing the *one thing* RN is good at (settlement) and keeping the parts they don't help with (UX, wallet generation, compliance). - 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. - Alternative: do the same with our own chain watcher (Alchemy webhooks / Tenderly / Goldsky) and skip RN entirely.
### What needs testing before we commit ### What still needs testing before we commit at scale
1. **Webhook reliability at our volume.** What's RN's SLA for "address received funds → webhook delivered"? P50? P99? 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. 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? 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? 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. 5. **What happens when the payment arrives from a transaction WE built** (not theirs)? The 2026-05-28 in-house checkout probe proved the basic path for a real BSC USDC payment; this still needs repeated paid probes across tokens/chains and webhook durability coverage.
Until #5 is confirmed, the rest is just paper architecture. Until webhook durability, destination divergence, pricing, and SLA are confirmed, treat RN as useful but not irreplaceable infrastructure.
---
## 5. Webhook durability remains P0 before production rollout
### What the 2026-05-28 probe proved
The dev test transaction `0x3a23febd9abd43d7e0851c1ea86c4ceaf08c11098852cb0425fa074e9c88350b` succeeded on BSC. RN then called `POST /api/payment/request-network/webhook` on `dev.amn.gg` four times from `34.34.233.192`. Amanat returned `404` because backend correlation looked up the wrong reference shape; the `Payment` record held RN request/payment-reference values that the handler did not search.
### Design implication
Do not treat the main Express app as the only webhook landing zone, and do not treat a signed provider callback as enough to credit escrow.
### Required mitigation and status
1. **Correlation repair:** implemented for the in-house checkout path; keep smoke coverage around every persisted RN reference shape.
2. **Callback repair:** implemented enough for the successful paid dev probe; keep polling/backoff hardening on the checkout roadmap.
3. **Transaction Safety Provider:** implemented for tx hash, confirmations, transfer match, and AML placeholder; real AML provider remains Task #10.
4. **Durable ingress:** not started. Put a Cloudflare Worker in front of RN webhooks. The Worker stores raw delivery evidence durably, forwards to the backend, and supports replay. It is not the trust oracle; the backend still verifies, deduplicates, and applies safety/ledger transitions.
--- ---
@@ -147,11 +167,13 @@ Until #5 is confirmed, the rest is just paper architecture.
| # | Action | Blocker / Owner | | # | Action | Blocker / Owner |
|---|---|---| |---|---|---|
| 1 | Test: payment via wallet-built transfer triggers RN webhook | Backend payments | | 1 | Run the live divergent-destination probe: two paid intents to two derived addresses | Backend payments |
| 2 | Test: `/v2/secure-payments` accepts a per-request destination wallet | Backend payments | | 2 | Confirm `/v2/secure-payments` per-request destination usage with RN support and pricing | Product / RN account manager |
| 3 | Confirm RN doesn't auto-bridge when buyer pays on the destination chain natively | 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 | | 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 | | 5 | Move sweep/release/refund custody to Trezor/Safe, not backend hot keys | Backend + ops |
| 6 | Spec the seller-side accepted-chains config | Backend + frontend | | 6 | Spec the seller-side accepted-chains config | Backend + frontend |
| 7 | Build Cloudflare Worker durable webhook ingress + replay | Backend / platform |
| 8 | Add AML/sanctions adapter behind Transaction Safety Provider | Compliance / backend |
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. Actions 1-4 are information-gathering and should run in parallel before deeper RN commitment. Actions 5, 7, and 8 are production-safety work regardless of whether Amanat keeps RN long-term or replaces it with a direct chain watcher.

View File

@@ -0,0 +1,199 @@
---
title: Scanner Architecture
tags: [architecture, scanner, payment]
created: 2026-05-30
---
# Scanner Architecture
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON.
> [!info]
> Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs.
---
## 1. Responsibilities
- Accept payment **intents** from the backend (POST /intents)
- Watch the relevant chain for matching on-chain transfers
- Track confirmation depth (EVM) or rely on finality from the chain API (Tron, TON)
- Deliver a signed webhook to the backend callback URL when confirmed
- Retry failed webhook deliveries
- Expire stale pending intents on a configurable TTL
---
## 2. Component map
```
┌─────────────────────────────────────────────────────────┐
│ scanner binary │
│ │
│ main.go │
│ ├── loadConfig() config.go │
│ ├── initDB() intent.go (SQLite schema) │
│ ├── startup reconcile intent.go │
│ ├── newServer() api.go │
│ │ └── startWorkers() api.go │
│ │ ├── ChainWorker chain.go (EVM) │
│ │ ├── TronChainWorker tron_chain.go (Tron) │
│ │ └── TonChainWorker ton_chain.go (TON) │
│ ├── HTTP routes api.go / main.go │
│ ├── intent TTL expiry main.go + intent.go │
│ └── webhook retry loop main.go + webhook.go │
│ │
│ reference.go — payment reference / topic hash math │
│ webhook.go — delivery, HMAC signing, retry │
└─────────────────────────────────────────────────────────┘
```
---
## 3. Chain worker model
All three chain types implement the `Worker` interface:
```go
type Worker interface {
start()
stop()
getHead(ctx context.Context) (int64, error)
}
```
One worker goroutine is spawned per chain marked `"verified": true` in `supported-chains.json`. Workers are selected by `chainType`:
| chainType | Worker struct | API used |
|---|---|---|
| `evm` (default) | `ChainWorker` | JSON-RPC 2.0 (`eth_getLogs`, `eth_blockNumber`) |
| `tron` | `TronChainWorker` | TronGrid REST (`/v1/contracts/{contract}/events`) |
| `ton` | `TonChainWorker` | TonCenter v3 REST (`/jetton/transfers`) |
Workers poll on `POLL_INTERVAL_SEC` (default 15 s). On first run, each worker starts scanning from the current chain head minus a small buffer (10 blocks for EVM, 24 h for Tron/TON).
---
## 4. EVM scanning detail
```
for each tick:
head = eth_blockNumber
from = max(checkpoint ReorgBuffer(), 0)
chunks = split [from..head] into 2000-block ranges
for each chunk:
logs = eth_getLogs(proxyAddress, EventTopic, from, to)
for each log:
topicRef = Topics[1] (keccak256 of paymentReference — pre-indexed)
intent = DB lookup by topicRef WHERE status='pending'
validate(log.Data, intent) ← token + destination + amount check
confirmIntentPending() ← status → 'confirming'
saveCheckpoint(to)
checkConfirmations():
for each confirming intent:
confs = head - blockNumber + 1
if confs >= required: finalizeIntent() + deliverWebhook()
```
**Reorg protection**: `ReorgBuffer()` re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20500). This catches any log that appeared in a block that was later reorganised off the canonical chain.
**Event signature**: `TransferWithReferenceAndFee` keccak256 = `0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3`
---
## 5. Tron scanning detail
TronGrid does not expose a fee-proxy contract. Each intent is assigned a unique HD-derived destination address. The scanner watches TRC20 `Transfer` events on the USDT contract and matches by `to` address.
- Checkpoint: block timestamp in milliseconds (`last_scanned_block` column)
- TronGrid addresses arrive as `41xxxx` hex (21 bytes); normalized to `0x` (20 bytes EVM style)
- Tron transactions reported by TronGrid are already confirmed; status goes directly to `confirmed` (no multi-block wait)
- Pagination follows `meta.links.next` until empty
---
## 6. TON scanning detail
TON uses TonCenter v3. Per-intent polling: for each pending TON intent, a separate HTTP call fetches incoming Jetton transfers to that destination since the checkpoint.
- Checkpoint: Unix timestamp in seconds
- TON addresses are base64url (`EQ…`/`UQ…`) — case-sensitive, never lowercased
- `proxyAddress` = USDT Jetton master address (`EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs`)
- TonCenter returns only finalized transactions; status goes directly to `confirmed`
- Lag is reported in seconds, not blocks
- Known scaling limitation: O(pending intents) API calls per scan cycle
---
## 7. Intent lifecycle
```
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
│ │ │
│ │ (deep reorg / TTL) │ (all retries fail)
└───────────────────────┴──────────► expired webhook_failed
```
- **Tron / TON** skip `confirming` and jump directly to `confirmed`.
- `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
- **Startup reconciliation**: on startup, `confirmed` intents with `webhook_delivered_at IS NULL` and created in the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook`.
---
## 8. Payment reference math (EVM)
```
paymentReference = last8Bytes(keccak256(lower(intentId + salt + destination)))
topicRef (index) = keccak256(paymentReferenceBytes)
```
The ERC20FeeProxy indexes `paymentReference` so `Topics[1]` in the log is `topicRef`, not the raw reference. The DB stores `topic_ref` pre-computed per intent so the scan loop is a single indexed SQL lookup instead of O(n) hashing.
---
## 9. Database schema (SQLite WAL)
Two tables:
**`intents`** — one row per payment intent
| Column | Type | Notes |
|---|---|---|
| `intent_id` | TEXT PK | caller-supplied UUID |
| `chain_id` | INTEGER | numeric chain ID |
| `chain_type` | TEXT | `evm` / `tron` / `ton` |
| `token_address` | TEXT | EVM/Tron: lowercase 0x hex; TON: base64url |
| `destination` | TEXT | receiving address |
| `amount` | TEXT | base-10 wei / token smallest unit |
| `payment_reference` | TEXT | 8-byte hex (EVM only) |
| `topic_ref` | TEXT | keccak256 of paymentReference (EVM index) |
| `status` | TEXT | `pending` / `confirming` / `confirmed` / `expired` / `webhook_failed` |
| `callback_url` | TEXT | backend webhook endpoint |
| `callback_secret` | TEXT | HMAC key (not returned in GET) |
| `confirmations_required` | INTEGER | from chain config or caller override |
| `tx_hash` | TEXT NULL | transaction hash once seen |
| `log_index` | INTEGER NULL | log position within tx (EVM) |
| `block_number` | INTEGER NULL | block / timestamp when seen |
| `confirmations` | INTEGER | current confirmation depth |
| `salt` | TEXT | 32-byte random hex for reference derivation |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp of successful delivery |
| `created_at` / `updated_at` | DATETIME | |
Unique index on `(tx_hash, log_index)` prevents duplicate intent confirmation.
**`checkpoints`** — one row per chain, tracks scan progress
| Column | Notes |
|---|---|
| `chain_id` | PK |
| `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) |
---
## 10. Security model
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
- If `SCANNER_API_KEY` is unset the server logs a warning and allows all requests — intended for local dev only.
- Webhooks are signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
- The `callbackSecret` is stored in the DB but excluded from all JSON responses (`json:"-"` tag).
- Request bodies are limited to 64 KB.

View File

@@ -9,7 +9,7 @@ created: 2026-05-23
How identity, authorization, transport, and integrity are handled across the platform. How identity, authorization, transport, and integrity are handled across the platform.
> [!important] > [!important]
> Read alongside [[Authentication Flow]] (user-facing), [[Passkey (WebAuthn) Flow]], and [[Payment Flow - SHKeeper]] (webhook HMAC). > Read alongside [[Authentication Flow]] (user-facing), [[Passkey (WebAuthn) Flow]], [[Escrow Flow]], and [[Request Network Integration Constraints]].
--- ---
@@ -22,7 +22,7 @@ How identity, authorization, transport, and integrity are handled across the pla
| CSRF | JWT in `Authorization` header (not cookie), CORS allow-list | | CSRF | JWT in `Authorization` header (not cookie), CORS allow-list |
| XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage | | XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage |
| SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation | | SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation |
| Webhook spoofing | HMAC SHA-256 over body + secret (SHKeeper, Request Network, Telegram), constant-time compare | | Webhook spoofing | HMAC SHA-256 over raw body + provider secret (Request Network, Telegram), constant-time compare |
| File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node | | File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node |
| Replay attacks | Per-payment idempotency on `providerPaymentId`; Telegram initData in-memory replay map; per-request `X-Request-Id` | | Replay attacks | Per-payment idempotency on `providerPaymentId`; Telegram initData in-memory replay map; per-request `X-Request-Id` |
| Account takeover | Email verification required, password reset code expiry (1h), passkey support | | Account takeover | Email verification required, password reset code expiry (1h), passkey support |
@@ -155,34 +155,36 @@ A single User may be `buyer` and `seller` simultaneously (combined role).
## 5. Webhook integrity ## 5. Webhook integrity
### 5.1 SHKeeper ### 5.1 Request Network
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant SHK participant RN
participant WK as Durable ingress (roadmap)
participant BE participant BE
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: sha256=<hmac> RN->>WK: POST /api/payment/request-network/webhook<br/>x-request-network-signature
BE->>BE: hmac = HMAC_SHA256(SHKEEPER_WEBHOOK_SECRET, body) WK->>WK: Store raw body + headers + delivery id
BE->>BE: crypto.timingSafeEqual(hmac, providedSig) WK->>BE: Forward / replay raw webhook
BE->>BE: verifyRequestNetworkWebhookSignature(rawBody, headers)
alt mismatch alt mismatch
BE-->>SHK: 401 Unauthorized BE-->>WK: 401 Unauthorized
else match else match
BE->>BE: process payment update BE->>BE: idempotency + Transaction Safety Provider
BE-->>SHK: 200 OK BE->>BE: process payment update / ledger entry
BE-->>WK: 200 OK
end end
``` ```
- Raw body must be used for HMAC — `express.raw({ type: 'application/json' })` is mounted on this route only (before the global `express.json()` parser).
- In dev (`NODE_ENV === 'development'`) signature verification can be bypassed for local testing — confirm this is gated and never reachable in prod.
- Idempotency: identical webhook delivered twice should be no-op. Check by `(providerPaymentId, status)` tuple before mutating.
### 5.2 Request Network
- Webhooks arrive at `/api/payment/request-network/webhook` with an `x-request-network-signature` header. - Webhooks arrive at `/api/payment/request-network/webhook` with an `x-request-network-signature` header.
- The backend verifies the signature using `backend/src/services/payment/requestNetwork/signature.ts` before any state mutation. - The backend verifies the signature using `backend/src/services/payment/requestNetwork/signature.ts` before any state mutation.
- The route is mounted **before** the global `express.json()` body parser so raw body bytes are available for signature computation. - The route is mounted **before** the global `express.json()` body parser so raw body bytes are available for signature computation.
- The global rate-limit middleware is configured to skip this path to avoid blocking high-frequency payment events. - The global rate-limit middleware is configured to skip this path to avoid blocking high-frequency payment events.
- Reconciliation service (`requestNetworkReconciliationService.ts`) handles replayed or out-of-order webhooks idempotently. - Reconciliation service (`requestNetworkReconciliationService.ts`) handles replayed or out-of-order webhooks idempotently.
- Durable ingress is the target production shape: the Worker stores delivery evidence and supports replay, but the backend remains the trust oracle.
### 5.2 Legacy SHKeeper note
SHKeeper-specific webhook docs are historical migration context. The current backend payment tree uses Request Network as the primary provider; do not reintroduce SHKeeper signature bypasses or fallback webhook heuristics without a new security review.
### 5.3 Telegram Bot webhook ### 5.3 Telegram Bot webhook
@@ -191,7 +193,7 @@ sequenceDiagram
- A per-update-id in-memory replay map prevents duplicate processing within the configured window. - A per-update-id in-memory replay map prevents duplicate processing within the configured window.
- The global rate-limit middleware is configured to skip this path. - The global rate-limit middleware is configured to skip this path.
See [[Payment Flow - SHKeeper]] for the SHKeeper full flow. See [[Escrow Flow]] and [[Request Network Integration Constraints]] for the current payment path.
--- ---
@@ -219,7 +221,7 @@ See [[Payment Flow - SHKeeper]] for the SHKeeper full flow.
- Never log secrets — logger redaction recommended (winston/pino formatter). - Never log secrets — logger redaction recommended (winston/pino formatter).
- `.env*` files in `.gitignore`. Repo includes only `.env.development` / `.env.production` templates with **public** values (NEXT_PUBLIC_*). - `.env*` files in `.gitignore`. Repo includes only `.env.development` / `.env.production` templates with **public** values (NEXT_PUBLIC_*).
- Rotate `JWT_SECRET` invalidates all existing JWTs — schedule a maintenance window. - Rotate `JWT_SECRET` invalidates all existing JWTs — schedule a maintenance window.
- Rotate `SHKEEPER_WEBHOOK_SECRET` coordinated with SHKeeper dashboard (set new → verify → remove old). - Rotate `REQUEST_NETWORK_WEBHOOK_SECRET` coordinated with Request Network configuration (set new → verify → remove old).
See [[Environment Variables]] for the catalog. See [[Environment Variables]] for the catalog.
@@ -277,6 +279,6 @@ The codebase currently uses `morgan` (HTTP access logs) and ad-hoc `logger.info/
- [[Authentication Flow]] (includes Telegram first-class auth flow) · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]] - [[Authentication Flow]] (includes Telegram first-class auth flow) · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
- [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]] - [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]]
- [[Payment Flow - SHKeeper]] — webhook HMAC details - [[Request Network Integration Constraints]] — payment webhook, checkout, and reconciliation constraints
- [[Environment Variables]] — secret catalog - [[Environment Variables]] — secret catalog
- [[Incident Response]] — what to do when something goes wrong - [[Incident Response]] — what to do when something goes wrong

View File

@@ -24,7 +24,8 @@ flowchart LR
BE[Express Backend<br/>+ Socket.IO<br/>:5001] BE[Express Backend<br/>+ Socket.IO<br/>:5001]
Mongo[(MongoDB 8)] Mongo[(MongoDB 8)]
Redis[(Redis 8)] Redis[(Redis 8)]
SHK[SHKeeper<br/>Crypto Gateway] RN[Request Network<br/>Pay-in + webhooks]
CFWorker[Durable webhook ingress<br/>roadmap]
SMTP[SMTP<br/>Nodemailer] SMTP[SMTP<br/>Nodemailer]
OAI[OpenAI API] OAI[OpenAI API]
BC[Blockchain RPC<br/>Alchemy / WalletConnect] BC[Blockchain RPC<br/>Alchemy / WalletConnect]
@@ -37,8 +38,9 @@ flowchart LR
FE -.->|Socket.IO| BE FE -.->|Socket.IO| BE
BE --> Mongo BE --> Mongo
BE --> Redis BE --> Redis
BE -->|Pay-in / Pay-out| SHK BE -->|Pay-in intent / status| RN
SHK -.->|Webhook HMAC| BE RN -.->|Signed webhook| CFWorker
CFWorker -.->|Forward / replay| BE
BE --> SMTP BE --> SMTP
BE --> OAI BE --> OAI
FE -->|Wallet Connect| BC FE -->|Wallet Connect| BC
@@ -142,25 +144,29 @@ Mutations follow optimistic-then-confirm:
### 5.3 Webhook path (inbound) ### 5.3 Webhook path (inbound)
External services (SHKeeper) POST to `/api/payment/shkeeper/webhook`. The backend verifies HMAC signature, updates the `Payment` document, advances any linked `PurchaseRequest`/`SellerOffer` state, and emits Socket.IO events to both buyer and seller rooms. External services POST payment callbacks to provider-specific webhook routes. The current primary path is Request Network at `/api/payment/request-network/webhook`; the target architecture puts a durable ingress worker in front of the backend so raw delivery evidence can be replayed after outages. The backend remains the trust oracle: it verifies signatures, deduplicates deliveries, applies Transaction Safety Provider checks, updates ledger/payment state, and emits Socket.IO events to both buyer and seller rooms.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant SHK as SHKeeper participant RN as Request Network
participant WK as Durable ingress worker
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant Buyer participant Buyer
participant Seller participant Seller
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: HMAC-SHA256 RN->>WK: POST signed webhook<br/>delivery id + raw body
BE->>BE: verifySignature(body, header, SHKEEPER_WEBHOOK_SECRET) WK->>WK: Store immutable delivery evidence
BE->>DB: Payment.updateOne({providerPaymentId}, {status:"completed"}) WK->>BE: Forward / replay webhook
BE->>DB: PurchaseRequest.updateOne(..., {status:"funded"}) BE->>BE: Verify RN signature + idempotency
BE->>BE: Transaction Safety Provider checks tx hash, recipient, token, amount, confirmations
BE->>DB: Append ledger entry + Payment escrowState="funded"
BE->>DB: PurchaseRequest.updateOne(..., {status:"payment"})
BE-->>Buyer: socket emit "payment:status-updated" BE-->>Buyer: socket emit "payment:status-updated"
BE-->>Seller: socket emit "request:funded" BE-->>Seller: socket emit "request:funded"
BE-->>SHK: 200 OK BE-->>WK: 200 OK
``` ```
See [[Payment Flow - SHKeeper]] for the full sequence. See [[PRD - Request Network In-House Checkout]] and [[Request Network Integration Constraints]] for the full Request Network sequence.
--- ---
@@ -199,4 +205,4 @@ See [[Payment Flow - SHKeeper]] for the full sequence.
- [[Real-time Layer]] — Socket.IO setup, rooms, events - [[Real-time Layer]] — Socket.IO setup, rooms, events
- [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC - [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC
- [[Tech Stack]] — exact versions & purpose of every dependency - [[Tech Stack]] — exact versions & purpose of every dependency
- [[Payment Flow - SHKeeper]] — end-to-end crypto pay-in flow - [[Escrow Flow]] — current Request Network pay-in, ledger, and custody release flow

View File

@@ -5,6 +5,7 @@ aliases: [Conversation, IChat, IMessage]
--- ---
# Chat # Chat
> **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))
Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`). Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`).
@@ -16,6 +17,9 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
> [!warning] Embedded messages > [!warning] Embedded messages
> Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema. > Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema.
> [!warning] `relatedTo` is NOT set via `POST /api/chat`
> Although `relatedTo` exists in the schema, it is **not accepted** by the `POST /api/chat` create endpoint. Purchase-request linkage is established server-side through the dedicated `POST /api/chat/purchase-request`, not by passing `relatedTo` to the generic create endpoint.
## Schema — Chat ## Schema — Chat
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
@@ -27,10 +31,10 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. | | `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. |
| `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. | | `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. |
| `participants[].lastSeen` | Date | no | — | — | — | Last activity. | | `participants[].lastSeen` | Date | no | — | — | — | Last activity. |
| `participants[].leftAt` | Date | no | — | — | — | If left, when. | | `participants[].leftAt` | Date | no | — | — | — | Set when the participant is removed (soft removal). |
| `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. | | `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. Set to `false` on soft removal (subdocument is kept). |
| `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. | | `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. |
| `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. | | `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. **Not accepted via `POST /api/chat`** — set only via `POST /api/chat/purchase-request`. |
| `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. | | `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. |
| `lastMessage.content` | String | no | — | — | — | Snapshot for list views. | | `lastMessage.content` | String | no | — | — | — | Snapshot for list views. |
| `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. | | `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. |
@@ -50,13 +54,16 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
> [!note] No top-level `timestamps` > [!note] No top-level `timestamps`
> Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook. > Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook.
> [!note] Soft removal of participants
> Removing a participant (via `DELETE /api/chat/:id/participants/:participantId`) does **not** delete the subdocument. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
## Schema — Message (embedded) ## Schema — Message (embedded)
| Field | Type | Required | Default | Validation | Description | | Field | Type | Required | Default | Validation | Description |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `senderId` | ObjectId → [[User]] | yes | — | — | Author. | | `senderId` | ObjectId → [[User]] | yes | — | — | Author. |
| `senderType` | String | no | `User` | — | Currently fixed. | | `senderType` | String | no | `User` | — | Currently fixed. |
| `content` | String | yes | — | maxlength 5000 | Message body. | | `content` | String | yes | — | **maxlength 5000** | Message body. Enforced at both schema and controller. |
| `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. | | `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. |
| `fileUrl` | String | no | — | — | If file/image. | | `fileUrl` | String | no | — | — | If file/image. |
| `fileName` | String | no | — | — | Original filename. | | `fileName` | String | no | — | — | Original filename. |
@@ -65,10 +72,14 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| `isRead` | Boolean | no | `false` | — | Read flag. | | `isRead` | Boolean | no | `false` | — | Read flag. |
| `isEdited` | Boolean | no | `false` | — | Edited flag. | | `isEdited` | Boolean | no | `false` | — | Edited flag. |
| `editedAt` | Date | no | — | — | When edited. | | `editedAt` | Date | no | — | — | When edited. |
| `deletedAt` | Date | no | — | — | Set on soft-delete; `content` is cleared but the subdocument is kept. |
| `replyTo` | ObjectId | no | — | — | Reply target message id. | | `replyTo` | ObjectId | no | — | — | Reply target message id. |
| `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. | | `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. |
| `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. | | `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. |
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message subdocument is **not** physically removed from `messages[]`, and a `message-deleted` socket event is emitted.
## Virtuals ## Virtuals
| Virtual | Returns | Definition | | Virtual | Returns | Definition |
@@ -97,7 +108,7 @@ Defined at `backend/src/models/Chat.ts:243-247`:
| --- | --- | | --- | --- |
| `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` | | `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` |
| `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` | | `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` |
| `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` | | `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all when `messageIds` is empty/omitted) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` |
## Static Methods ## Static Methods

View File

@@ -0,0 +1,49 @@
---
title: ConfigSettingHistory
tags: [data-model, mongoose, admin, audit]
aliases: [Setting History, Threshold History, IConfigSettingHistory]
created: 2026-05-30
---
# ConfigSettingHistory
> **Added:** 2026-05-30 — introduced in commit `27fb15a` as part of Task #9 (per-chain confirmation thresholds + audit log).
Audit trail document that records every change to a runtime configuration setting. Currently used exclusively to log confirmation-threshold updates (`key` pattern: `confirmation_threshold:<chainId>`), but the schema is generic and can store other numeric runtime config changes.
> [!note] Source
> `backend/src/models/ConfigSettingHistory.ts` — schema and model export.
> Written by `backend/src/services/payment/safety/confirmationThresholdService.ts` (`setConfirmationThreshold`).
> Read by `GET /api/admin/settings/confirmation-thresholds/history` in `confirmationThresholdRoutes.ts`.
## Schema
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key` | String | yes | — | Setting identifier. Format: `confirmation_threshold:<chainId>` for threshold changes. Indexed. |
| `oldValue` | Number | no | `null` | Value before the change. `null` when the setting had no prior database entry. |
| `newValue` | Number | yes | — | Value after the change. |
| `changedBy` | ObjectId (ref: `User`) | no | — | Admin user who made the change. Populated by `GET …/history` via `.populate('changedBy', 'email name')`. |
| `changedAt` | Date | no | `Date.now()` | Timestamp of the change. Indexed; used for sort-descending pagination. |
> [!note] No `timestamps: false`
> The schema deliberately disables Mongoose's automatic `createdAt`/`updatedAt` fields (`timestamps: false`) because `changedAt` is the canonical timestamp.
## Example document
```json
{
"_id": "6657c3...",
"key": "confirmation_threshold:56",
"oldValue": 12,
"newValue": 6,
"changedBy": { "_id": "...", "email": "admin@amn.gg" },
"changedAt": "2026-05-30T10:22:00.000Z"
}
```
## Related
- [[Payment API]] — `GET /api/admin/settings/confirmation-thresholds/history`
- [[Admin API]] — confirmation thresholds section
- `backend/src/services/payment/safety/confirmationThresholdService.ts`

View File

@@ -9,26 +9,17 @@ aliases: [Models Index, Schema Overview]
This section documents every Mongoose model that backs the marketplace. The persistence layer lives in `backend/src/models/` and is exported through a single barrel file at `backend/src/models/index.ts`. All models target MongoDB via Mongoose, lean on `timestamps: true` for `createdAt` / `updatedAt`, and follow a consistent pattern: one schema per file, an exported `I<Name>` TypeScript interface, and named exports for the compiled model. This section documents every Mongoose model that backs the marketplace. The persistence layer lives in `backend/src/models/` and is exported through a single barrel file at `backend/src/models/index.ts`. All models target MongoDB via Mongoose, lean on `timestamps: true` for `createdAt` / `updatedAt`, and follow a consistent pattern: one schema per file, an exported `I<Name>` TypeScript interface, and named exports for the compiled model.
> [!note] Scope > [!note] Scope
> Eighteen models are documented here. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below. > Twenty-two models are present in `backend/src/models/`. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below.
> >
> [!warning] Implementation gap > [!note] Documentation freshness
> As of the 2026-05-24 audit, the following documented models **do not yet have Mongoose schema files** in `backend/src/models/`: > The 2026-05-24 audit note that marked `Dispute`, `BlogPost`, `Review`, `PointTransaction`, `LevelConfig`, and `ShopSettings` as missing is now stale: schema files exist for those models. Newer operational models such as [[ConfigSetting]], [[DerivedDestination]], [[FundsLedgerEntry]], and [[TrezorAccount]] should be expanded into dedicated model pages when the docs are next deepened.
> - [[Dispute]]
> - [[BlogPost]]
> - [[Review]]
> - [[PointTransaction]]
> - [[LevelConfig]]
> - [[ShopSettings]]
> The following *are* implemented in code and are documented accurately:
> - [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]], [[Chat]], [[Notification]], [[RequestTemplate]], [[Address]], [[Category]], [[TempVerification]], [[TelegramLink]], [[TelegramSession]]
> Additionally, `FundsLedgerEntry.ts` and `TrezorAccount.ts` exist in `backend/src/models/` but are not yet documented in this vault.
## Index of Models ## Index of Models
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, and admins all live in this collection, differentiated by a `role` enum. - [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, and admins all live in this collection, differentiated by a `role` enum.
- [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes. - [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes.
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). - [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`).
- [[Payment]] — Records every monetary movement: buyer pay-in, seller payout, refund. Integrates with the SHKeeper crypto gateway and tracks escrow state plus on-chain transaction metadata. - [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata.
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. - [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]].
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. - [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id.
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. - [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal.
@@ -43,6 +34,11 @@ This section documents every Mongoose model that backs the marketplace. The pers
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes. - [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes.
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `userId` and `telegramUserId`). - [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `userId` and `telegramUserId`).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `initDataFingerprint` for replay protection and auto-expires via a MongoDB TTL index on `expiresAt`. - [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `initDataFingerprint` for replay protection and auto-expires via a MongoDB TTL index on `expiresAt`.
- [[ConfigSetting]] — Runtime configuration persisted in MongoDB for operational knobs that need an admin surface rather than a deploy.
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins.
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events.
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening.
- [[ConfigSettingHistory]] — Immutable audit trail of numeric runtime-config changes. Currently used for per-chain confirmation threshold change events, keyed as `confirmation_threshold:<chainId>`. Added in commit `27fb15a`.
## Relationship Diagram ## Relationship Diagram
@@ -59,6 +55,7 @@ erDiagram
USER ||--o{ REVIEW : "writes as reviewer" USER ||--o{ REVIEW : "writes as reviewer"
USER ||--o{ DISPUTE : "raises as buyer" USER ||--o{ DISPUTE : "raises as buyer"
USER ||--o{ USER : "referred by" USER ||--o{ USER : "referred by"
USER ||--o{ TREZOR_ACCOUNT : "controls custody account"
PURCHASE_REQUEST }o--|| CATEGORY : "belongs to" PURCHASE_REQUEST }o--|| CATEGORY : "belongs to"
PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives" PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives"
@@ -72,6 +69,8 @@ erDiagram
PAYMENT }o--|| USER : "buyer" PAYMENT }o--|| USER : "buyer"
PAYMENT }o--|| USER : "seller" PAYMENT }o--|| USER : "seller"
PAYMENT ||--o{ FUNDS_LEDGER_ENTRY : "accounted by"
PAYMENT ||--o| DERIVED_DESTINATION : "collects into"
CHAT }o--o{ USER : "participants" CHAT }o--o{ USER : "participants"
CHAT ||--o{ DISPUTE : "support channel" CHAT ||--o{ DISPUTE : "support channel"
@@ -109,11 +108,11 @@ The dominant happy-path flow exercises five collections in order:
1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`. 1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`.
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers``in_negotiation` as the parties chat in a `Chat`. 2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers``in_negotiation` as the parties chat in a `Chat`.
3. The buyer accepts an offer; a `Payment` is opened against the SHKeeper provider with `escrowState: 'funded'`. 3. The buyer accepts an offer; a `Payment` is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`. 4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`.
5. The escrow `Payment` flips to `released` and a payout `Payment` (`direction: 'out'`) is issued. Optionally the buyer writes a `Review` and earns a `PointTransaction`. 5. The escrow `Payment` flips to `released` after a ledger-gated custody transfer instruction. Optionally the buyer writes a `Review` and earns a `PointTransaction`.
If anything goes sideways, the buyer can open a `Dispute` (planned but not yet implemented), which would freeze the flow until an admin resolves it (refund, replacement, compensation, or no-action). If anything goes sideways, the buyer can open a `Dispute`, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
## How to Navigate ## How to Navigate

View File

@@ -6,13 +6,16 @@ aliases: [Complaint, IDispute]
# Dispute # Dispute
> **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))
Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`). Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`).
> [!warning] Missing model > [!note] Implementation status
> **`backend/src/models/Dispute.ts` does not exist** as of the 2026-05-24 audit. The `Dispute` model, service layer, and API routes are **documented but not yet implemented** in the backend. The schema below reflects the *intended* design only. > `backend/src/models/Dispute.ts`, `backend/src/services/dispute/DisputeService.ts`, `backend/src/routes/disputeRoutes.ts`, and release-hold helper routes now exist. The remaining gap is canonical state alignment between the full dispute document and the lighter `PurchaseRequest`/`Payment` hold flags used by release gating.
> >
> Source (intended): `backend/src/models/Dispute.ts:69` — schema definition > Source: `backend/src/models/Dispute.ts` — schema definition and model export.
> `backend/src/models/Dispute.ts:238` — model export
> ⚠️ **SECURITY** — The dispute `status` update endpoint and the `resolve` endpoint currently have **no role guards**. Any authenticated user (not just admins) can modify dispute status or submit a resolution. This is a known gap pending a role-guard audit.
## Schema ## Schema
@@ -50,6 +53,22 @@ Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, prior
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
### Category enum
Valid values: `product_quality` · `delivery_delay` · `wrong_item` · `payment_issue` · `seller_behavior` · `other`
**Note:** `fraud` is **not** a valid category value. Use `seller_behavior` or `other` for fraud-related complaints.
### Status enum
Valid values: `pending` · `in_progress` · `waiting_response` · `resolved` · `rejected` · `closed`
**Note:** `under_review` does **not** exist in the schema. The equivalent lifecycle state is `in_progress`.
### Resolution action enum
Valid values: `refund` · `replacement` · `compensation` · `warning_seller` · `ban_seller` · `no_action`
> [!note] `messages` in the interface > [!note] `messages` in the interface
> The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`. > The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
@@ -59,7 +78,7 @@ None defined.
## Indexes ## Indexes
Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*: Defined at `backend/src/models/Dispute.ts`:
- `{ purchaseRequestId: 1 }` - `{ purchaseRequestId: 1 }`
- `{ buyerId: 1 }` - `{ buyerId: 1 }`
@@ -76,7 +95,7 @@ Defined at `backend/src/models/Dispute.ts:212-223` *(intended)*:
| Hook | Behaviour | | Hook | Behaviour |
| --- | --- | | --- | --- |
| `pre('save')` (`backend/src/models/Dispute.ts:226` *(intended)*) | On new documents pushes a `dispute_created` entry into `timeline` attributed to `buyerId`. | | `pre('save')` (`backend/src/models/Dispute.ts`) | On new documents pushes a `dispute_created` entry into `timeline` attributed to `buyerId`. |
## Instance Methods ## Instance Methods

View File

@@ -6,7 +6,9 @@ aliases: [User Notification, INotification]
# Notification # Notification
Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index. > **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))
Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index (`createdAt` with `expireAfterSeconds = 7,776,000`).
> [!note] Source > [!note] Source
> `backend/src/models/Notification.ts:18` — schema definition > `backend/src/models/Notification.ts:18` — schema definition
@@ -15,6 +17,12 @@ Per-user notification entry. Each row binds to one `userId` (stored as a string
> [!warning] String userId > [!warning] String userId
> `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]]. > `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]].
> [!warning] `category` enum vs reality
> The schema enum is `purchase_request` / `offer` / `payment` / `delivery` / `system`, but in practice:
> - `notificationController.createNotification` defaults the category to **`'general'`** (`category = 'general'`) when the caller omits it. `'general'` is **not** in the schema enum — Mongoose enum validation will reject it on a strict save, so callers must supply a valid value or the write fails. Treat `'general'` as a value you may encounter in payloads even though it is not an enum member.
> - The frontend socket hook `use-notifications.ts` hardcodes `category: 'system'` for every realtime-injected notification, so most client-side notifications surface as `'system'` regardless of their true domain.
> - `NotificationService.notifyRequestStatusChanged` always writes `category: 'system'` for purchase-request status changes.
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
@@ -23,13 +31,13 @@ Per-user notification entry. Each row binds to one `userId` (stored as a string
| `title` | String | yes | — | maxlength 200 | — | Headline. | | `title` | String | yes | — | maxlength 200 | — | Headline. |
| `message` | String | yes | — | maxlength 1000 | — | Body. | | `message` | String | yes | — | maxlength 1000 | — | Body. |
| `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. | | `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. |
| `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. | | `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. ⚠️ `notificationController` defaults to `'general'` (not in the enum) and the realtime socket hook + `notifyRequestStatusChanged` hardcode `'system'`. See warning above. |
| `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). | | `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). |
| `metadata` | Mixed | no | — | — | — | Arbitrary payload. | | `metadata` | Mixed | no | — | — | — | Arbitrary payload. |
| `actionUrl` | String | no | — | maxlength 500 | — | Deep link. | | `actionUrl` | String | no | — | maxlength 500 | — | Deep link. |
| `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. | | `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. |
| `readAt` | Date | no | — | — | — | When read. | | `readAt` | Date | no | — | — | — | When read. |
| `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. Auto-deleted after 90 days by TTL index. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
The collection name is overridden to `notifications` via `collection: 'notifications'`. The collection name is overridden to `notifications` via `collection: 'notifications'`.
@@ -46,7 +54,7 @@ Defined at `backend/src/models/Notification.ts:71-77`:
- `{ userId: 1, isRead: 1 }` — unread badge. - `{ userId: 1, isRead: 1 }` — unread badge.
- `{ userId: 1, category: 1 }` — category filter. - `{ userId: 1, category: 1 }` — category filter.
- `{ relatedId: 1 }` — lookup by linked entity. - `{ relatedId: 1 }` — lookup by linked entity.
- `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` — auto-delete after 90 days. - `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` (7,776,000 s) — MongoDB TTL index; the database hard-deletes documents automatically after 90 days.
Plus the implicit index from `userId` having `index: true` at the field level. Plus the implicit index from `userId` having `index: true` at the field level.
@@ -62,6 +70,13 @@ None defined.
None defined. None defined.
## Status-change notification coverage
`NotificationService.notifyRequestStatusChanged` maps a [[PurchaseRequest]] status to a human label via an internal `statusMessages` table. That table covers `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, and `cancelled`.
> [!warning] Missing status templates
> The `pending_payment` and `seller_paid` [[PurchaseRequest]] statuses have **no entry** in the `statusMessages` table and no dedicated notification template. Transitions into these states do not produce a meaningful status-change notification (the label falls back to the raw status string, and several flows skip notification entirely). If you rely on notifications for `pending_payment` / `seller_paid`, they will not arrive as expected.
## Relationships ## Relationships
- **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`. - **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`.

View File

@@ -6,7 +6,9 @@ aliases: [Payment Record, Escrow, IPayment]
# Payment # Payment
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. Designed around the SHKeeper crypto payment gateway with explicit fields for blockchain network, transaction hash, escrow state, and provider invoice ids. The `provider` and `direction` discriminators let one collection hold all four flow types (incoming buyer payment, outgoing seller payout, refund, and "other" provider integrations). > **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))
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.
> [!note] Source > [!note] Source
> `backend/src/models/Payment.ts:3` — schema definition > `backend/src/models/Payment.ts:3` — schema definition
@@ -15,6 +17,22 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
> [!warning] Mixed types > [!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. > `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.
> [!warning] `confirmed` vs `completed` — stats undercount
> Payment stats (`paymentService.getPaymentStats`) only increment `successfulPayments` for status **`confirmed`**:
> ```ts
> case "confirmed": stats.successfulPayments += stat.count; break;
> ```
> The terminal SHKeeper / DePay state is **`completed`**, which has no case in the switch and is therefore **not** counted as a successful payment. ⚠️ This causes successful-payment stats to undercount any payment that reached `completed`.
> [!warning] `SIM_` payment-hash bypass — security concern
> In both `payment/paymentRoutes.ts` and `marketplace/routes.ts`, a `paymentHash` that starts with `SIM_` (or a short `0x...` hash under 64 chars) is treated as a simulated transaction and **skips on-chain verification entirely** (`isVerified = true`). There is **no environment guard** (e.g. no `NODE_ENV !== 'production'` check) around this branch, so the bypass is reachable in production. ⚠️ A caller can mark a payment verified without any real on-chain settlement.
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
@@ -25,7 +43,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). | | `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
| `amount.amount` | Number | yes | — | — | — | Numeric amount. | | `amount.amount` | Number | yes | — | — | — | Numeric amount. |
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. | | `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
| `provider` | String | no | `shkeeper` | enum: `shkeeper` / `request.network` / `request-network` / `other` | yes (compound, partial) | Payment processor. | | `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. |
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. | | `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
| `blockchain.network` | String | no | — | — | — | Network identifier. | | `blockchain.network` | String | no | — | — | — | Network identifier. |
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. | | `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
@@ -35,8 +53,8 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `blockchain.receiver` | String | no | — | — | — | Destination address. | | `blockchain.receiver` | String | no | — | — | — | Destination address. |
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. | | `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. | | `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. | | `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. ⚠️ `confirmed` vs `completed`: only `confirmed` is counted as a successful payment in stats. See status note below. |
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. | | `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. Note the intermediate states `releasable` (delivery confirmed, ready to pay out) and `releasing` (payout in flight) between `funded` and `released`. |
| `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. | | `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. |
| `metadata.userAgent` | String | no | — | — | — | Browser UA. | | `metadata.userAgent` | String | no | — | — | — | Browser UA. |
| `metadata.ipAddress` | String | no | — | — | — | Client IP. | | `metadata.ipAddress` | String | no | — | — | — | Client IP. |
@@ -55,6 +73,8 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. | | `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. |
| `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. | | `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. |
| `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. | | `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | Mixed | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | Object | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. | | `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. |
| `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. | | `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. |
| `metadata.createdVia` | String | no | — | — | — | Origin marker. | | `metadata.createdVia` | String | no | — | — | — | Origin marker. |

View File

@@ -4,9 +4,19 @@ tags: [data-model, mongoose]
aliases: [Point Ledger, Loyalty Transaction, IPointTransaction] aliases: [Point Ledger, Loyalty Transaction, IPointTransaction]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# PointTransaction # PointTransaction
Append-only ledger of loyalty point movements. Each row represents one earn / spend / expire event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]). > **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))
Append-only ledger of loyalty point movements. Each row represents one `earn` / `spend` / `expire` event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]).
> [!warning] `type` enum is `earn` / `spend` / `expire` ONLY
> There is **no `refund` type** (nor any other value). The `enum` at `PointTransaction.ts:35` is exactly `['earn', 'spend', 'expire']`. Referral earns are identified by `source: 'referral'` + `type: 'earn'`, **not** by a dedicated type.
> [!danger] `expire` is defined but never produced
> The `expiresAt` field and the `'expire'` type exist in the schema, and there is a sparse `{ expiresAt: 1 }` index intended for expiry sweeps — but **no service, cron job, or TTL ever creates an `expire`-type transaction**. Point expiry is **not enforced** anywhere in the codebase today; points effectively never expire.
> [!note] Source > [!note] Source
> `backend/src/models/PointTransaction.ts:25` — schema definition > `backend/src/models/PointTransaction.ts:25` — schema definition
@@ -18,7 +28,7 @@ Append-only ledger of loyalty point movements. Each row represents one earn / sp
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. | | `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. |
| `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. | | `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. |
| `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. | | `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. **Referral earns are identified by `source='referral'` (with `type='earn'`), not by type.** Redemptions use `source='redemption'`; admin grants use `source='admin'`. |
| `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). | | `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). |
| `balance` | Number | yes | — | — | — | Available balance after the move. | | `balance` | Number | yes | — | — | — | Available balance after the move. |
| `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). | | `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). |
@@ -67,7 +77,7 @@ None defined.
## State Transitions ## State Transitions
No status field — entries are immutable once written. A consumer scans for `expiresAt < now` to create offsetting `type: 'expire'` rows. No status field — entries are immutable once written. The schema anticipates a consumer scanning for `expiresAt < now` to create offsetting `type: 'expire'` rows, but **no such consumer exists**: nothing in the codebase ever writes an `expire` row, so in practice only `earn` and `spend` entries are ever created.
## Common Queries ## Common Queries

View File

@@ -6,6 +6,8 @@ aliases: [Purchase Request, Buy Request, IPurchaseRequest]
# PurchaseRequest # PurchaseRequest
> **Last updated:** 2026-05-30 — `budget.currency` locked to USDT; `categoryId` added to `IRequestTableItem`
The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code. The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code.
> [!note] Source > [!note] Source
@@ -18,7 +20,7 @@ The central buyer-side document. A `PurchaseRequest` captures what a buyer wants
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Buyer that owns the request. | | `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Buyer that owns the request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Short headline. | | `title` | String | yes | — | trim, maxlength 200 | — | Short headline. |
| `description` | String | yes | — | trim, maxlength 2000 | — | Long form description. | | `description` | String | yes | — | trim, minlength 5 (frontend), maxlength 2000 | — | Long form description. Frontend enforces a 5-character minimum; the field is optional in the raw schema but the form will reject shorter values. |
| `categoryId` | ObjectId → [[Category]] | yes | — | — | yes | Category the request belongs to. | | `categoryId` | ObjectId → [[Category]] | yes | — | — | yes | Category the request belongs to. |
| `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes | What kind of fulfilment is expected. | | `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes | What kind of fulfilment is expected. |
| `productLink` | String | no | — | trim, must match `/^https?:\/\/.+/` | — | Reference URL for the desired product. | | `productLink` | String | no | — | trim, must match `/^https?:\/\/.+/` | — | Reference URL for the desired product. |
@@ -29,9 +31,9 @@ The central buyer-side document. A `PurchaseRequest` captures what a buyer wants
| `quantity` | Number | no | `1` | min 1 | — | Unit count. | | `quantity` | Number | no | `1` | min 1 | — | Unit count. |
| `budget.min` | Number | no | — | min 0 | — | Lower bound. | | `budget.min` | Number | no | — | min 0 | — | Lower bound. |
| `budget.max` | Number | no | — | min 0 | — | Upper bound. | | `budget.max` | Number | no | — | min 0 | — | Upper bound. |
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Budget currency. | | `budget.currency` | String | no | `USDT` | enum: `USDT` (escrow MVP — USD/EUR/IRR removed in commit 3aaa2fe) | — | Budget currency. |
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Buyer urgency. | | `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Buyer urgency. |
| `status` | String | no | `pending` | enum (13 values, see below) | yes | Lifecycle state. | | `status` | String | no | `pending` | enum (13 values see State Transitions below) | yes | Lifecycle state. |
| `isPublic` | Boolean | no | `true` | — | — | Public marketplace listing vs. private request. | | `isPublic` | Boolean | no | `true` | — | — | Public marketplace listing vs. private request. |
| `tags[]` | String[] | no | — | trim | — | Free-form tags. | | `tags[]` | String[] | no | — | trim | — | Free-form tags. |
| `specifications[].key` | String | yes | — | trim | — | Spec key. | | `specifications[].key` | String | yes | — | trim | — | Spec key. |
@@ -84,6 +86,12 @@ The central buyer-side document. A `PurchaseRequest` captures what a buyer wants
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
### Status enum — all valid values
`pending_payment` · `pending` · `active` · `received_offers` · `in_negotiation` · `payment` · `processing` · `delivery` · `delivered` · `confirming` · `completed` · `seller_paid` · `cancelled`
**Note:** `finalized` and `archived` are **not** valid status values and do not appear in the `IPurchaseRequest` frontend type or the Mongoose schema enum. Using either would cause a validation error.
## Virtuals ## Virtuals
None defined. None defined.

View File

@@ -0,0 +1,114 @@
---
title: ScannerIntent (Scanner DB model)
tags: [data-model, scanner, payment]
created: 2026-05-30
---
# ScannerIntent
SQLite row in the AMN Pay Scanner's `intents` table. One row per payment intent registered by the backend. This is internal scanner state — it is not a Mongoose model and lives in a separate SQLite database (`/data/scanner.db`).
---
## Schema
```sql
CREATE TABLE intents (
intent_id TEXT PRIMARY KEY,
chain_id INTEGER NOT NULL,
chain_type TEXT NOT NULL DEFAULT 'evm',
token_address TEXT NOT NULL,
destination TEXT NOT NULL,
amount TEXT NOT NULL,
payment_reference TEXT NOT NULL,
topic_ref TEXT,
status TEXT NOT NULL DEFAULT 'pending',
callback_url TEXT NOT NULL,
callback_secret TEXT NOT NULL,
confirmations_required INTEGER NOT NULL DEFAULT 12,
tx_hash TEXT,
log_index INTEGER,
block_number INTEGER,
confirmations INTEGER NOT NULL DEFAULT 0,
salt TEXT NOT NULL,
webhook_delivered_at TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
---
## Fields
| Field | Type | Description |
|---|---|---|
| `intent_id` | TEXT PK | Caller-supplied unique ID (typically the backend Payment `_id`) |
| `chain_id` | INTEGER | Numeric chain ID. EVM standard (56, 137, 1, 42161, 8453), Tron (728126428), TON (1100) |
| `chain_type` | TEXT | `evm` / `tron` / `ton`. Determines which worker handles this intent |
| `token_address` | TEXT | ERC20 / TRC20 contract address. EVM/Tron: lowercase `0x` hex. TON: exact base64url |
| `destination` | TEXT | Recipient wallet address. EVM/Tron: lowercase `0x` hex. TON: base64url (case-sensitive) |
| `amount` | TEXT | Required amount in smallest token unit (wei / 10^decimals), stored as base-10 integer string |
| `payment_reference` | TEXT | 8-byte hex EVM payment reference (`0x` + 16 hex chars). Derived as `last8(keccak256(intentId + salt + destination))` |
| `topic_ref` | TEXT | `keccak256(paymentReferenceBytes)` — matches `Topics[1]` in EVM logs. Pre-computed for indexed DB lookup. NULL for Tron/TON |
| `status` | TEXT | Intent lifecycle state (see below) |
| `callback_url` | TEXT | URL the scanner POSTs to on confirmation |
| `callback_secret` | TEXT | HMAC-SHA256 key for webhook signature. Never returned in API responses |
| `confirmations_required` | INTEGER | Number of blocks required before confirmation (EVM). Defaults to chain config |
| `tx_hash` | TEXT NULL | Transaction hash once a matching transfer is detected |
| `log_index` | INTEGER NULL | Log position within the transaction (EVM only; 0 for Tron/TON) |
| `block_number` | INTEGER NULL | Block number (EVM/Tron) or Unix timestamp seconds (TON) when the tx was seen |
| `confirmations` | INTEGER | Current confirmation depth. Incremented each scan cycle for `confirming` intents |
| `salt` | TEXT | 32-byte random hex. Combined with `intent_id` and `destination` to derive `payment_reference`. Prevents reference collisions across retried payments |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp when the webhook was successfully delivered. Used for startup crash recovery |
| `created_at` / `updated_at` | DATETIME | UTC timestamps |
---
## Status values
| Status | Description |
|---|---|
| `pending` | Registered; scanner is watching for a matching on-chain transfer |
| `confirming` | EVM only — matching tx seen, waiting for `confirmations_required` blocks |
| `confirmed` | Payment confirmed; webhook delivery attempted |
| `expired` | TTL exceeded while still in `pending` or `confirming` |
| `webhook_failed` | All webhook delivery retries exhausted; manual retry or periodic auto-retry needed |
---
## Indexes
```sql
CREATE INDEX idx_intents_status ON intents(status);
CREATE INDEX idx_intents_chain_status ON intents(chain_id, status);
CREATE INDEX idx_intents_payment_ref ON intents(payment_reference);
CREATE INDEX idx_intents_topic_ref ON intents(topic_ref);
CREATE UNIQUE INDEX idx_intents_tx_log ON intents(tx_hash, log_index)
WHERE tx_hash IS NOT NULL;
```
`idx_intents_topic_ref` is the performance-critical index — the EVM scanner's inner loop does a single indexed lookup per log entry.
The unique index on `(tx_hash, log_index)` prevents two intents being confirmed from the same on-chain event (double-spend protection).
---
## Migrations
Three additive migrations run at startup (idempotent):
1. `ADD COLUMN topic_ref TEXT` — added after initial schema
2. `ADD COLUMN chain_type TEXT NOT NULL DEFAULT 'evm'` — added for Tron/TON support
3. `ADD COLUMN webhook_delivered_at TEXT` — added for crash recovery
A backfill pass recomputes `topic_ref` for existing EVM intents that had it as NULL.
---
## Related
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
- [Payment](Payment.md) — the backend MongoDB model that triggers intent creation

View File

@@ -6,6 +6,8 @@ aliases: [Seller Offer, Bid, ISellerOffer]
# SellerOffer # SellerOffer
> **Last updated:** 2026-05-30 — added AML fields (`requireAmlCheck`, `amlBlockOnFailure`)
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`. 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`.
> [!note] Source > [!note] Source
@@ -28,9 +30,13 @@ A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the del
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. | | `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
| `notes` | String | no | — | trim | — | Internal/private notes. | | `notes` | String | no | — | trim | — | Internal/private notes. |
| `validUntil` | Date | no | — | — | — | Expiration. | | `validUntil` | Date | no | — | — | — | Expiration. |
| `requireAmlCheck` | Boolean | no | — | — | — | If true, AML screening must pass before the offer is presented to the buyer. |
| `amlBlockOnFailure` | Boolean | no | — | — | — | If true and AML screening fails, the offer is blocked. Otherwise it is flagged for manual review. |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. | | `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | 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.
## Virtuals ## Virtuals
None defined. None defined.
@@ -56,6 +62,20 @@ None defined.
None defined. None defined.
## Service notes
### `createOffer` — eligible parent request statuses
`createOffer` in `SellerOfferService` permits offers against a `PurchaseRequest` whose status is **`pending`**, **`received_offers`**, or **`active`**. Attempts against any other status are rejected.
### `withdrawOffer()` — frontend action available
`SellerOfferService.withdrawOffer()` is not a dedicated HTTP route. The correct API path is `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
The frontend exposes this via the `withdrawOffer(offerId)` action in `src/actions/marketplace.ts` (added commit 240a668). It is called from:
- `step-2-waiting-for-payment.tsx` (edit/cancel controls while `requestDetails.status === 'received_offers'`)
- `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` (Offer Management page, bulk view)
## Relationships ## Relationships
- **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`). - **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`).

View File

@@ -6,12 +6,20 @@ aliases: [User Model, IUser, Account]
# User # User
> **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))
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` reference back to `User`, so this collection is the relational hub of the system. The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` reference back to `User`, so this collection is the relational hub of the system.
> [!note] Source > [!note] Source
> `backend/src/models/User.ts:70` — schema definition > `backend/src/models/User.ts:70` — schema definition
> `backend/src/models/User.ts:257` — model export > `backend/src/models/User.ts:257` — model export
> [!note] Email change re-verification
> When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`).
> [!note] Wallet ownership proof
> `PATCH /api/user/wallet-address` accepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (`ethers.verifyMessage`); TON addresses are format-validated and may include an optional TonProof. A successful proof sets `profile.walletProofVerified = true` and `profile.walletProofTimestamp`.
## Schema ## Schema
| Field | Type | Required | Default | Validation | Index | Description | | Field | Type | Required | Default | Validation | Index | Description |
@@ -20,8 +28,8 @@ The core identity document for every actor in the marketplace: buyers, sellers,
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. | | `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. |
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). | | `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). |
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). | | `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). |
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` | yes | Authorisation tier. | | `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` / `resolver` | yes | Authorisation tier. `resolver` was added in commit `fce8a19` — can view and resolve disputes, and bypass chat membership checks, but has no other admin privileges. |
| `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after [[TempVerification]] is consumed. | | `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after the email verification code is consumed. ⚠️ Changing the email via `PUT /api/user/profile` **resets this to `false`** and dispatches a fresh **6-digit** verification code to the new address (see Email verification note below). |
| `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. | | `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. |
| `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. | | `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. |
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. | | `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. |
@@ -48,7 +56,11 @@ The core identity document for every actor in the marketplace: buyers, sellers,
| `profile.address.country` | String | no | — | — | — | — | | `profile.address.country` | String | no | — | — | — | — |
| `profile.bio` | String | no | — | — | — | Free-form bio. | | `profile.bio` | String | no | — | — | — | Free-form bio. |
| `profile.website` | String | no | — | — | — | Personal website URL. | | `profile.website` | String | no | — | — | — | Personal website URL. |
| `profile.walletAddress` | String | no | — | — | — | On-chain wallet address. | | `profile.walletAddress` | String | no | — | — | — | On-chain wallet address (EVM `0x…` or TON). Set via `PATCH /api/user/wallet-address`. |
| `profile.walletType` | String | no | — | enum: `evm` / `ton` | — | Which chain family the stored `walletAddress` belongs to. |
| `profile.walletProvider` | String | no | — | — | — | Wallet provider label (e.g. `evm`, `telegram-wallet`). Defaults to `telegram-wallet` for TON, `evm` otherwise. |
| `profile.walletProofVerified` | Boolean | no | — | — | — | True when ownership was proven — EIP-191 signature for EVM, or a verified TonProof for TON. |
| `profile.walletProofTimestamp` | Date | no | — | — | — | When the wallet proof was last verified (only set when `walletProofVerified` is true). |
| `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. | | `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. |
| `preferences.language` | String | no | `"en"` | — | — | UI language. | | `preferences.language` | String | no | `"en"` | — | — | UI language. |
| `preferences.currency` | String | no | `"USD"` | — | — | Display currency. | | `preferences.currency` | String | no | `"USD"` | — | — | Display currency. |
@@ -57,7 +69,7 @@ The core identity document for every actor in the marketplace: buyers, sellers,
| `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. | | `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. |
| `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. | | `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. |
| `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. | | `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. |
| `refreshTokens[]` | String[] | no | `[]` | — | — | Outstanding JWT refresh tokens. | | `refreshTokens[]` | String[] | no | `[]` | — | — | Array of currently active JWT refresh tokens. ⚠️ Reset to `[]` on password change and on password reset, which invalidates every outstanding session and forces re-login everywhere. |
| `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** in `User.ts` — planned for referral programme. | | `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `referredBy` | ObjectId → User | no | — | — | yes | **Not yet implemented** in `User.ts` — planned for referral programme. | | `referredBy` | ObjectId → User | no | — | — | yes | **Not yet implemented** in `User.ts` — planned for referral programme. |
| `points.total` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts` — planned for loyalty system. | | `points.total` | Number | no | `0` | — | — | **Not yet implemented** in `User.ts` — planned for loyalty system. |

View File

@@ -12,13 +12,13 @@ This page is the entry point for the API. See the individual service pages for e
- [[Authentication API]] - register/login/passkeys/Google OAuth - [[Authentication API]] - register/login/passkeys/Google OAuth
- [[User API]] - profile, wallet, admin user management - [[User API]] - profile, wallet, admin user management
- [[Marketplace API]] - purchase requests, seller offers, templates, shop, reviews - [[Marketplace API]] - purchase requests, seller offers, templates, shop, reviews
- [[Payment API]] - SHKeeper, Web3, DePay, payouts - [[Payment API]] - Request Network, in-house checkout, ledger-gated release/refund
- [[Chat API]] - conversations and messages - [[Chat API]] - conversations and messages
- [[Notification API]] - in-app notifications - [[Notification API]] - in-app notifications
- [[Dispute API]] - dispute resolution *(planned, not yet implemented)* - [[Dispute API]] - dispute creation, assignment, evidence, resolution
- [[Blog API]] - blog posts *(planned, not yet implemented)* - [[Blog API]] - blog posts
- [[Admin API]] - user management, data cleanup *(planned, not yet implemented)* - [[Admin API]] - user management, data cleanup, RN/admin payment settings
- [[Points API]] - loyalty points, levels, referrals *(planned, not yet implemented)* - [[Points API]] - loyalty points, levels, referrals
- [[AI API]] - OpenAI-backed text endpoints - [[AI API]] - OpenAI-backed text endpoints
- [[File API]] - upload, delete, serve - [[File API]] - upload, delete, serve
- [[Socket Events]] - real-time events - [[Socket Events]] - real-time events
@@ -34,7 +34,9 @@ This page is the entry point for the API. See the individual service pages for e
The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`). The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`).
Health check (not under `/api`): `GET /health``{ success, message, timestamp, environment, version }`. Health checks:
- `GET /health` (not under `/api`) → `{ success, message, timestamp, environment, version }` — used by Docker and Gatus.
- `GET /api/health` (added in commit `44579d6`, backend v2.6.49) → deeper JSON with database and Redis connectivity status, plus the version string. Used by Gatus monitoring.
API discovery endpoint: `GET /api` → returns a map of available service prefixes. API discovery endpoint: `GET /api` → returns a map of available service prefixes.
@@ -157,7 +159,7 @@ cors({
}) })
``` ```
Only the configured `FRONTEND_URL` may make cross-origin requests with credentials. The SHKeeper configuration endpoint (`GET /api/payment/shkeeper/config`) overrides this with `Access-Control-Allow-Origin: *` because it is consumed by the SHKeeper payment widget hosted on another domain. Only the configured `FRONTEND_URL` may make cross-origin requests with credentials. Provider webhooks and Telegram bot webhooks are server-to-server entrypoints and should be exempted through explicit route handling, not broad browser CORS.
Uploaded files served from `/uploads/*` use `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so they can be embedded from the frontend domain. Uploaded files served from `/uploads/*` use `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so they can be embedded from the frontend domain.

View File

@@ -5,30 +5,45 @@ tags: [api, admin, reference]
# Admin API # Admin API
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'`. The two enforcement patterns are: > **Last updated:** 2026-05-30 — break-glass endpoints added, scanner/status auth fixed, reload/probe routes now implemented, confirmation threshold history implemented, resolver role added
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'` unless explicitly noted otherwise. The two enforcement patterns are:
- Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers). - Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers).
- Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes). - Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes).
> [!note] Resolver role
> The `resolver` role was added (commit `fce8a19`). Resolvers have access to the dispute-triage endpoints (`assign`, `status`, `resolve`, `statistics`) only. All other admin endpoints remain `admin`-only.
## User management ## User management
See full descriptions in [[User API]]. See full descriptions in [[User API]].
> **Path note:** The frontend and backend both use `/api/users/admin/*` (plural). The singular `/api/user/admin/*` paths for create/delete/status/role/list are **unreachable** — they are not mounted in the backend. Use `/api/users/admin/*` for all user-management calls.
| Endpoint | Action | | Endpoint | Action |
| --- | --- | | --- | --- |
| `POST /api/user/admin/create` | Create user with role/status | | `POST /api/users/admin/create` | Create user with role/status |
| `DELETE /api/user/admin/:userId` | Delete user (admins cannot delete each other) | | `DELETE /api/users/admin/:userId` | Soft delete user — sets `status='deleted'` (admins cannot delete each other) |
| `PATCH /api/user/admin/:userId/status` | Activate / suspend | | `PATCH /api/users/admin/:userId/status` | Activate / suspend |
| `PATCH /api/user/admin/:userId/toggle-status` | Flip active flag | | `PATCH /api/users/admin/:userId/toggle-status` | Flip active flag |
| `PATCH /api/user/admin/:userId/role` | Change role | | `PATCH /api/users/admin/:userId/role` | Change role |
| `GET /api/user/admin/list` | Paginated directory + stats | | `GET /api/users/admin/list` | Paginated directory + stats |
| `GET /api/user/admin/:userId/dependencies` | Pre-delete dependency check | | `GET /api/users/admin/:userId/dependencies` | Pre-delete dependency check |
| `GET /api/users/admin/stats` | Aggregate user analytics | | `GET /api/users/admin/stats` | Aggregate user analytics |
| `GET /api/users/admin/:userId` | Full user detail (admin view) | | `GET /api/users/admin/:userId` | Full user detail (admin view) |
| `PUT /api/users/admin/:userId` | Mass update user | | `PUT /api/users/admin/:userId` | Mass update user |
| `PUT /api/users/admin/update/:email` | Mass update by email | | `PUT /api/users/admin/update/:email` | Mass update by email |
| `PATCH /api/users/admin/:userId/password` | Force password reset (wipes refresh tokens) | | `PATCH /api/users/admin/:userId/password` | Force password reset (wipes refresh tokens) |
| `POST /api/users/admin/:userId/resend-verification` | Resend verification email | | `POST /api/users/admin/:userId/resend-verification` | Resend verification email (legacy route — uses 8-digit codes) |
> **Verification code length:** The endpoint `POST /api/users/admin/:userId/resend-verification` is served by the legacy userRoutes and generates **8-digit** codes. The new userController generates 6-digit codes and is reached via a different path. Both coexist; the legacy route takes precedence for this path.
**⚠️ KNOWN BUG — HTTP verb mismatch (status/role updates):** The frontend Redux actions for `updateUserStatus` and `updateUserRole` send `PUT` requests, but the backend registers these handlers under `PATCH`. These calls will receive `404 Method Not Found` responses until the frontend is corrected to use `PATCH`.
**⚠️ KNOWN BUG — Status value mismatch:** The frontend sends `'inactive'` and `'pending'` as status values when updating user status. The backend only accepts `'active'`, `'suspended'`, or `'deleted'`. Sending `'inactive'` or `'pending'` will be rejected or silently ignored.
**Hard vs. soft delete note:** The legacy route `DELETE /users/admin/:id` performs a **hard delete** (`findByIdAndDelete`). The current route `DELETE /api/users/admin/:userId` performs a **soft delete** (sets `status='deleted'`). Always use the current `/api/users/admin/:userId` route to preserve data integrity.
## Listing / marketplace moderation ## Listing / marketplace moderation
@@ -62,14 +77,32 @@ See [[Payment API]].
| `POST /api/payment/payments/cleanup-pending` | Delete stale pending payments | | `POST /api/payment/payments/cleanup-pending` | Delete stale pending payments |
| `POST /api/payment/payments/:id/fetch-tx` | Re-query chain for missing tx hash | | `POST /api/payment/payments/:id/fetch-tx` | Re-query chain for missing tx hash |
| `POST /api/payment/payments/auto-fetch-missing` | Batch tx-hash backfill | | `POST /api/payment/payments/auto-fetch-missing` | Batch tx-hash backfill |
| `POST /api/payment/shkeeper/:id/release` | Build escrow-release tx | | `POST /api/payment/:id/release` | Build escrow-release tx |
| `POST /api/payment/shkeeper/:id/release/confirm` | Confirm release tx hash | | `POST /api/payment/:id/release/confirm` | Confirm release tx hash |
| `POST /api/payment/shkeeper/:id/refund` | Build refund tx | | `POST /api/payment/:id/refund` | Build refund tx |
| `POST /api/payment/shkeeper/:id/refund/confirm` | Confirm refund tx hash | | `POST /api/payment/:id/refund/confirm` | Confirm refund tx hash |
| `POST /api/payment/shkeeper/payout` | Create payout task | | `POST /api/payment/shkeeper/payout` | Create payout task |
| `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry | | `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry |
| `POST /api/payment/decentralized/admin-payout` | Direct admin-wallet payout | | `POST /api/payment/decentralized/admin-payout` | Direct admin-wallet payout |
**⚠️ Path correction:** Release/refund routes do **not** include a `/shkeeper/` segment. The correct paths are `/api/payment/:id/release`, `/api/payment/:id/release/confirm`, etc. (Previously documented incorrectly as `/api/payment/shkeeper/:id/…`.)
## Derived destinations & sweep
Frontend page: `/dashboard/admin/derived-destinations`. Backend registers 7 endpoints under `/api/payment/derived-destinations/*` with admin auth.
| Endpoint | Action |
| --- | --- |
| `GET /api/payment/derived-destinations` | List all derived destination addresses |
| `POST /api/payment/derived-destinations/sweep/trigger` | Trigger a sweep across all destinations |
| `POST /api/payment/derived-destinations/sweep/trigger/:id` | Trigger sweep for a single destination |
| `GET /api/payment/derived-destinations/sweep/cron/status` | Get sweep cron job status |
| `POST /api/payment/derived-destinations/sweep/cron/start` | Start the sweep cron job |
| `POST /api/payment/derived-destinations/sweep/cron/stop` | Stop the sweep cron job |
| `GET /api/payment/derived-destinations/sweep/history` | Sweep history log |
> Frontend action functions: `getDerivedDestinations`, `triggerSweep`, `triggerSingleSweep`, `getSweepCronStatus`, `startSweepCron`, `stopSweepCron`.
## Points (admin) ## Points (admin)
See [[Points API]]. See [[Points API]].
@@ -125,12 +158,100 @@ Router: [`backend/src/services/admin/dataCleanupRoutes.ts`](../../backend/src/se
**Description:** Seeds users, addresses, and templates in dependency order. Used to bootstrap a fresh staging environment. **Description:** Seeds users, addresses, and templates in dependency order. Used to bootstrap a fresh staging environment.
## Scanner / monitoring
### GET /api/admin/scanner/status
**Description:** Returns the current state of the AMN Pay Scanner. Proxies to `AMN_SCANNER_URL/scanner/status`.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` were added in commit `1d881c5`. The previously documented unauthenticated access gap (ISSUE-006) is closed.
### POST /api/admin/scanner/webhooks/retry
**Description:** Trigger a retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Settings
### AML settings
> **⚠️ RUNTIME-ONLY PERSISTENCE:** `PATCH /api/admin/settings/aml` updates `process.env` at runtime only. Changes are **lost on server restart**. There is no frontend page for these endpoints.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/settings/aml` | admin | Read current AML settings |
| `PATCH /api/admin/settings/aml` | admin | Update AML settings (runtime only — not persisted to disk or DB) |
**AML providers available:**
- **Chainalysis** — cloud API provider (requires `CHAINALYSIS_API_KEY`). Enabled via `AML_PROVIDER=chainalysis`.
- **OFAC SDN local** — downloads the US Treasury SDN XML list once per 24 hours and checks addresses locally. No API key required. Enabled via `AML_PROVIDER=ofac`. Added in commit `31343d1` (Task #10). List is fetched from `OFAC_SDN_URL` (defaults to `https://www.treasury.gov/ofac/downloads/sdn.xml`).
The active provider is selected at startup via `AML_PROVIDER`. `PATCH /api/admin/settings/aml` can switch the provider at runtime but the change is not persisted.
### Confirmation thresholds
Frontend page exists. Endpoints require admin auth.
| Endpoint | Action |
| --- | --- |
| `GET /api/admin/settings/confirmation-thresholds` | Get current confirmation thresholds for all chains |
| `PATCH /api/admin/settings/confirmation-thresholds/:chainId` | Update threshold for a specific chain |
| `GET /api/admin/settings/confirmation-thresholds/history` | Last 50 threshold change events (populated with `changedBy` user email/name) |
> **History route:** `GET /api/admin/settings/confirmation-thresholds/history` is now implemented (commit `27fb15a`). It reads from the `ConfigSettingHistory` collection, keyed as `confirmation_threshold:<chainId>`.
### Break-glass (Trezor bypass)
Three endpoints manage the break-glass mode, which disables the Trezor safekeeping requirement for escrow release/refund for up to 1 hour. All changes fire a Telegram alert.
| Endpoint | Action |
| --- | --- |
| `GET /api/admin/settings/break-glass` | Read current break-glass status (active, expiresAt, activatedBy) |
| `POST /api/admin/settings/break-glass` | Activate break-glass for 1 hour |
| `DELETE /api/admin/settings/break-glass` | Cancel break-glass before it expires |
> [!warning] In-memory state
> Break-glass state is stored in-memory only (`breakGlassRoutes.ts`). A server restart always clears it, which is intentional. The `isBreakGlassActive()` helper is exported and consumed by the Trezor safekeeping middleware.
## Payments awaiting confirmation
Frontend page exists.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/payments/awaiting-confirmation` | admin | List payments pending blockchain confirmation |
## RN network registry
Frontend page exists.
| Endpoint | Auth | Action |
| --- | --- | --- |
| `GET /api/admin/rn/networks` | admin | List all registered RN networks |
| `POST /api/admin/rn/networks/reload` | admin | Reload chain + token registries from disk (no restart needed) |
| `POST /api/admin/rn/networks/probe/:chainId` | admin | On-demand on-chain probe: RPC reachability, proxy bytecode, dummy-call validity |
> All three routes are implemented (commit `5681abf`). Previous docs listed reload and probe as not implemented.
## Blog admin
Backend registers 5 blog admin endpoints, all guarded by `authorizeRoles('admin')`. Frontend has action functions calling each.
| Endpoint | Action |
| --- | --- |
| `GET /api/blog/admin/posts` | List all blog posts (admin view, includes drafts) |
| `POST /api/blog/posts` | Create a new blog post |
| `GET /api/blog/admin/posts/:id` | Get a single blog post (admin view) |
| `PUT /api/blog/posts/:id` | Update a blog post |
| `DELETE /api/blog/posts/:id` | Delete a blog post |
## Analytics ## Analytics
There is no dedicated analytics router. Admin dashboards stitch together: There is no dedicated analytics router. Admin dashboards stitch together:
- `GET /api/users/admin/stats` (user metrics) - `GET /api/users/admin/stats` (user metrics)
- `GET /api/payment/stats` (payment aggregates) - `GET /api/payment/stats` (payment aggregates — note: `'completed'` status is excluded from `successfulPayments` count)
- `GET /api/disputes/statistics` (dispute KPIs) - `GET /api/disputes/statistics` (dispute KPIs)
- `GET /api/admin/cleanup/stats` (collection sizes) - `GET /api/admin/cleanup/stats` (collection sizes)
- `GET /api/payment/shkeeper/webhook-stats` (provider health) - `GET /api/payment/shkeeper/webhook-stats` (provider health)

View File

@@ -5,15 +5,19 @@ tags: [api, auth, reference]
# Authentication API # Authentication API
> **Last updated:** 2026-05-30 — Cloudflare Turnstile CAPTCHA added after 3 failed logins (commit `b8edbbf`)
All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts). All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts).
Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[TempVerification]] document that holds pending registration data until the email code is confirmed. Tokens are signed JWTs (access + refresh) created in `authService`. See [[Authentication Flow]] for the high-level lifecycle diagram. Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[TempVerification]] document that holds pending registration data until the email code is confirmed. Tokens are signed JWTs (access + refresh) created in `authService`. See [[Authentication Flow]] for the high-level lifecycle diagram.
**Token refresh behaviour:** The Axios interceptor handles `401` responses to trigger a token refresh. `403` errors are **not** intercepted and propagate directly to callers.
## Registration ## Registration
### POST /api/auth/register ### POST /api/auth/register
**Description:** Start a new registration. Creates a [[TempVerification]] document and emails an 8-digit verification code. The actual [[User]] is only created once the code is verified. **Description:** Start a new registration. Creates a [[TempVerification]] document and emails a **6-digit** verification code. The actual [[User]] is only created once the code is verified.
**Auth required:** No **Auth required:** No
**Request body:** **Request body:**
```ts ```ts
@@ -45,7 +49,7 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
```ts ```ts
{ {
email: string; email: string;
code: string; // 8 digits code: string; // 6 digits (generated by authService.generateVerificationCode())
password?: string; // required if not provided at register password?: string; // required if not provided at register
} }
``` ```
@@ -76,7 +80,7 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
### POST /api/auth/resend-verification ### POST /api/auth/resend-verification
**Description:** Re-issues the 8-digit code for a pending or unverified user. **Description:** Re-issues the 6-digit code for a pending or unverified user.
**Auth required:** No **Auth required:** No
**Request body:** `{ email: string }` **Request body:** `{ email: string }`
**Response 200:** `{ "success": true, "message": "Verification code resent" }` **Response 200:** `{ "success": true, "message": "Verification code resent" }`
@@ -116,6 +120,15 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
- `401` invalid credentials - `401` invalid credentials
- `403` email not verified - `403` email not verified
- `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`) - `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`)
**Cloudflare Turnstile CAPTCHA:** After **3 failed login attempts** from the same IP within 15 minutes the `captchaGate` middleware requires a valid `cf-turnstile-response` token in the request body. Responses when CAPTCHA is required but missing:
```json
{ "success": false, "captchaRequired": true, "message": "..." }
```
HTTP status: `429`. When `TURNSTILE_SECRET_KEY` is not set (local dev) the gate is skipped.
**⚠️ Rate limiter behaviour:** The attempt counter increments on **every** attempt (before password validation), not only on failures. 5 total attempts within 15 minutes triggers lockout — a user burning 5 attempts with typos will be locked out even if they never had a valid password.
**Side effects:** **Side effects:**
- Updates `user.lastLoginAt`. - Updates `user.lastLoginAt`.
- Pushes refresh token onto `user.refreshTokens`. - Pushes refresh token onto `user.refreshTokens`.
@@ -194,7 +207,9 @@ Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[Temp
## Passkey / WebAuthn ## Passkey / WebAuthn
Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyService.ts`. Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyService.ts`. These routes go directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). No Next.js route handlers exist for passkey paths.
**Implementation status:** Passkey attestation is **fully implemented** using `@simplewebauthn/server`. The registration and authentication flows are production-ready.
### POST /api/auth/passkey/authenticate/challenge ### POST /api/auth/passkey/authenticate/challenge
@@ -247,7 +262,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### POST /api/auth/reset-password ### POST /api/auth/reset-password
**Description:** Sets a new password using a token from the reset email. Wipes refresh tokens. **Description:** Sets a new password using a token from the reset email. Wipes refresh tokens. Enforces password complexity via `passwordResetValidation`.
**Auth required:** No **Auth required:** No
**Request body:** **Request body:**
```ts ```ts
@@ -261,10 +276,11 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### POST /api/auth/reset-password-with-code ### POST /api/auth/reset-password-with-code
**Description:** Alternative reset flow using a numeric code instead of a tokenised URL. **Description:** Alternative reset flow using a **6-digit** numeric code instead of a tokenised URL.
**Auth required:** No **Auth required:** No
**Request body:** `{ email, code, password }` **Request body:** `{ email, code, password }`
**Response 200:** `{ "success": true }` **Response 200:** `{ "success": true }`
**⚠️ No password complexity validation:** Unlike `POST /api/auth/reset-password` (token-based), this endpoint does **not** run `passwordResetValidation`. Any non-empty password will be accepted without complexity checks.
### POST /api/auth/change-password ### POST /api/auth/change-password
@@ -280,6 +296,7 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
**Response 200:** `{ "success": true, "message": "Password updated" }` **Response 200:** `{ "success": true, "message": "Password updated" }`
**Errors:** `400` validation, `401` wrong current password. **Errors:** `400` validation, `401` wrong current password.
**Side effects:** Clears `user.refreshTokens` (forces re-login on other devices). **Side effects:** Clears `user.refreshTokens` (forces re-login on other devices).
**⚠️ No frontend UI:** This endpoint exists and is functional in the backend, but no frontend page currently exposes a change-password form. It can only be called directly.
## Current user / profile ## Current user / profile
@@ -316,13 +333,15 @@ Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyServi
### DELETE /api/auth/account ### DELETE /api/auth/account
**Description:** Permanently deletes the caller's account after re-authenticating with password. **Description:** Permanently deletes the caller's account after re-authenticating with password. Requires `{ password }` in the request body and runs `deleteAccountValidation`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** `{ password: string }` **Request body:** `{ password: string }`
**Response 200:** `{ "success": true, "message": "Account deleted" }` **Response 200:** `{ "success": true, "message": "Account deleted" }`
**Errors:** `401` bad password. **Errors:** `401` bad password.
**Side effects:** Removes [[User]] document, clears Redis session, cascades configured by `dataCleanupService`. **Side effects:** Removes [[User]] document, clears Redis session, cascades configured by `dataCleanupService`.
**⚠️ KNOWN BUG — Frontend calls wrong endpoint:** The frontend currently calls `DELETE /user/profile` instead of `DELETE /api/auth/account`. Account deletion initiated from the frontend UI will fail or hit the wrong handler.
## Error codes summary ## Error codes summary
| HTTP | App code | Meaning | | HTTP | App code | Meaning |

View File

@@ -5,10 +5,23 @@ tags: [api, chat, reference]
# Chat API # Chat API
> **Last updated:** 2026-05-30 — admin and resolver roles can now read and send messages in any chat (commit `766a9a2`)
All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally. All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally.
> [!note] Admin and resolver chat access
> Users with role `admin` or `resolver` can **read messages and send messages in any chat** without being a listed participant (`ChatService` checks `canBypassMembership = senderRole === 'admin' || senderRole === 'resolver'`). This applies to `GET /api/chat/:id/messages`, `GET /api/chat/:id/info`, and `POST /api/chat/:id/messages`. Dispute-chat monitoring for resolvers was the primary driver (commit `766a9a2`).
Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads. Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads.
## Rate limits and constraints
| Rule | Value |
| --- | --- |
| Messages per user per minute | **20** |
| Edit window | **15 minutes** after send |
| Maximum message length | **5 000 characters** |
## Conversations ## Conversations
### POST /api/chat ### POST /api/chat
@@ -59,9 +72,9 @@ Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<ch
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
**Errors:** `403` not a participant, `404` not found. **Errors:** `403` not a participant, `404` not found.
### PATCH /api/chat/:id/archive ### PUT /api/chat/:id/archive
**Description:** Toggle archived state for the caller (per-user flag). **Description:** Toggle archived state for the caller (per-user flag). Calling this endpoint on an already-archived chat **unarchives** it (toggle semantics).
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
### POST /api/chat/:id/participants ### POST /api/chat/:id/participants
@@ -112,16 +125,18 @@ Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<ch
**Response 201:** `{ success, data: { message: { attachments: [{ url, filename, mimeType, size }] } } }` **Response 201:** `{ success, data: { message: { attachments: [{ url, filename, mimeType, size }] } } }`
> ⚠️ **KNOWN BUG** — The frontend `sendFileMessage` function incorrectly posts to `POST /api/chat/:id/messages` (the plain-text endpoint) instead of `POST /api/chat/:id/messages/file`. File uploads are currently broken as a result; the attachment is silently dropped or the request is rejected.
### PATCH /api/chat/:id/messages/read ### PATCH /api/chat/:id/messages/read
**Description:** Mark all unread messages up to the latest as read for the caller. **Description:** Mark messages as read for the caller. Passing an empty `messageIds` array (or omitting it) marks **all** messages in the chat as read.
**Auth required:** Bearer JWT (participant) **Auth required:** Bearer JWT (participant)
**Response 200:** `{ success, data: { modifiedCount } }` **Response 200:** `{ success, data: { modifiedCount } }`
**Side effects:** Emits `messages-read` on `chat-<id>`. **Side effects:** Emits `messages-read` on `chat-<id>`.
### PUT /api/chat/:id/messages/:messageId ### PUT /api/chat/:id/messages/:messageId
**Description:** Edit an existing message (author only, within edit window). **Description:** Edit an existing message (author only, within the 15-minute edit window).
**Auth required:** Bearer JWT (message author) **Auth required:** Bearer JWT (message author)
**Request body:** `{ content: string }` **Request body:** `{ content: string }`
**Side effects:** Emits `message-edited` on `chat-<id>`. **Side effects:** Emits `message-edited` on `chat-<id>`.

View File

@@ -5,12 +5,26 @@ tags: [api, dispute, reference]
# Dispute API # Dispute API
> [!warning] Not implemented > **Last updated:** 2026-05-30 — resolver role added, role guards applied to assign/status/resolve (commits b9e0f6a, 1d881c5)
> The Dispute module is **documented but not yet implemented** in the backend. There is no `backend/src/services/dispute/` directory, no `backend/src/routes/disputeRoutes.ts`, and no `/api/disputes` mount in `app.ts`. The API specification below reflects the *intended* design only.
Endpoints are planned to live under `/api/disputes/*`. The router would be `backend/src/routes/disputeRoutes.ts` and delegate to `DisputeController` (`backend/src/controllers/disputeController.ts`). The router would apply `authenticateToken` globally — every endpoint requires `Bearer JWT`. > [!note] Current implementation
> The Dispute module has two distinct router families. Keep this page aligned with both `backend/src/routes/disputeRoutes.ts` and `backend/src/services/dispute/disputeRoutes.ts`.
Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[Payment]] and is the input to the mediation workflow that ends in either a `resolved_buyer` or `resolved_seller` decision and triggers an escrow release or refund via the [[Payment API]]. Endpoints live under two prefixes:
- `/api/disputes/*``backend/src/routes/disputeRoutes.ts` delegates to `DisputeController` (`backend/src/controllers/disputeController.ts`) for CRUD/triage. All routes apply `authenticateToken` globally.
- `/api/disputes/pr/*``backend/src/services/dispute/disputeRoutes.ts` provides lightweight release-hold endpoints (`raise`, `resolve`, `status`) used by escrow release gating. Previously mounted at `/api/disputes`, causing route shadowing (ISSUE-003). **Remounted at `/api/disputes/pr` in commit `1d881c5`** — all release-hold calls must use this new prefix.
> [!success] Route shadowing resolved (ISSUE-003)
> The release-hold router was remounted from `/api/disputes` to `/api/disputes/pr`. Both routers now have independent paths and neither shadows the other.
> [!note] Resolver role
> A new `resolver` role was added (commit `fce8a19`). Resolvers can view and resolve disputes but have no other platform privileges. They are granted the same access as `admin` on all dispute-triage operations listed below.
> [!note] Real-time events
> All socket events from `DisputeService` are currently **TODO stubs**. No real-time events fire from dispute mutations. Notifications are delivered via `POST /api/notifications` → `new-notification` socket event only.
Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[Payment]] context and is the input to the mediation workflow that can lead to refund, replacement, compensation, warning/ban, or no-action. Release/refund execution should go through the ledger-gated [[Payment API]] and [[Payout Flow]].
## Create ## Create
@@ -22,18 +36,35 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
```ts ```ts
{ {
purchaseRequestId: string; purchaseRequestId: string;
reason: "not_delivered" | "wrong_item" | "damaged" | "quality" | "other"; reason: "product_quality" | "delivery_delay" | "wrong_item" | "payment_issue" | "seller_behavior" | "other";
description: string; description: string;
evidence?: string[]; // URLs from [[File API]] evidence?: string[]; // URLs from [[File API]]
paymentId?: string; paymentId?: string;
} }
``` ```
> **Note:** Valid `reason` values are `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. The value `fraud` does not exist.
**Response 201:** `{ success: true, data: { dispute } }` **Response 201:** `{ success: true, data: { dispute } }`
**Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request. **Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request.
**Side effects:** **Side effects:**
- Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event). - Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event).
- Pauses any in-flight payout (sets a hold flag on the related [[Payment]]). - Pauses any in-flight payout (sets a hold flag on the related [[Payment]]).
### POST /api/disputes/pr/:purchaseRequestId/raise
**Description:** Lightweight release-hold endpoint that marks a purchase request and related payments as disputed. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer who owns the request or admin)
**Request body:** `{ reason?: string }`
**Response 200:** `{ success, message, data }`
> **Path note:** Previously served at `/api/disputes/:purchaseRequestId/raise`. Moved to `/api/disputes/pr/:purchaseRequestId/raise` in commit `1d881c5` (ISSUE-003 fix).
### GET /api/disputes/pr/:purchaseRequestId/status
**Description:** Returns release-hold flags for a purchase request, including whether release is currently blocked. No corresponding frontend UI action.
**Auth required:** Bearer JWT (buyer, preferred seller, or admin)
## Read ## Read
### GET /api/disputes ### GET /api/disputes
@@ -41,15 +72,19 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
**Description:** List disputes the caller can see (their own as buyer/seller, all for admins). **Description:** List disputes the caller can see (their own as buyer/seller, all for admins).
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** **Query params:**
- `status` (`open` | `under_review` | `resolved_buyer` | `resolved_seller` | `closed`) - `status` (`open` | `in_progress` | `resolved_buyer` | `resolved_seller` | `closed`)
> **Note:** The status value `under_review` does not exist. Use `in_progress`.
- `purchaseRequestId` - `purchaseRequestId`
- `page`, `limit`, `sortBy`, `sortOrder` - `page`, `limit`, `sortBy`, `sortOrder`
**Response 200:** `{ success, data: { disputes, pagination } }` **Response 200:** `{ success, data: { disputes, pagination } }`
### GET /api/disputes/statistics ### GET /api/disputes/statistics
**Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards. **Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver``authorizeRoles('admin', 'resolver')` is applied)
**Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }` **Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }`
### GET /api/disputes/:id ### GET /api/disputes/:id
@@ -62,36 +97,49 @@ Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[P
### POST /api/disputes/:id/assign ### POST /api/disputes/:id/assign
**Description:** Assign an admin moderator to the dispute. Sets `assignedAdminId` and transitions status to `under_review`. **Description:** Assign an admin or resolver moderator to the dispute. Sets `assignedAdminId` and transitions status to `in_progress`.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ adminId: string }` **Request body:** `{ adminId: string }`
**Side effects:** Notifies all participants. **Side effects:** Notifies all participants.
### PATCH /api/disputes/:id/status ### PATCH /api/disputes/:id/status
**Description:** Generic status update (e.g. close without resolution). **Description:** Generic status update (e.g. close without resolution).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
**Request body:** `{ status: string; note?: string }` **Request body:** `{ status: string; note?: string }`
### POST /api/disputes/:id/resolve ### POST /api/disputes/:id/resolve
**Description:** Final adjudication. Records the decision and triggers the appropriate escrow action. **Description:** Final adjudication. Records the decision and triggers the appropriate escrow action.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (`admin` or `resolver`)
> ⚠️ **ROUTE SHADOWING:** Because the dashboard router is mounted before the admin-guarded release-hold router, this handler intercepts all `POST /api/disputes/:id/resolve` requests. The admin-guarded release-hold resolve endpoint is unreachable at this path.
**Request body:** **Request body:**
```ts ```ts
{ {
decision: "buyer" | "seller" | "split"; action: "refund" | "replacement" | "compensation" | "warning_seller" | "ban_seller" | "no_action";
refundAmount?: number; // required when "split" amount?: string; // optional, e.g. for partial refund or compensation amount
releaseAmount?: number; // required when "split" notes?: string;
reasoning: string;
} }
``` ```
**Response 200:** `{ success, data: { dispute, paymentAction } }` **Response 200:** `{ success, data: { dispute, paymentAction } }`
**Side effects:** **Side effects:**
- `decision === "buyer"` → triggers `POST /api/payment/shkeeper/:id/refund` flow. - `action === "refund"` → create/approve the corresponding refund instruction through the ledger-gated payment release/refund flow.
- `decision === "seller"` → triggers `POST /api/payment/shkeeper/:id/release` flow. - `action === "no_action"` or seller-favorable outcome → clear hold only after release checks pass.
- `decision === "split"` → admin executes both partial release and partial refund manually.
- Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`. - Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`.
- **ISSUE-004 fix (commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold so payment release is unblocked automatically after resolution.
### POST /api/disputes/pr/:purchaseRequestId/resolve
**Description:** Lightweight release-hold endpoint that clears the disputed hold flags on a purchase request and related payments.
**Auth required:** Bearer JWT (admin)
> **Path note:** Previously unreachable due to route shadowing. Moved to `/api/disputes/pr/:purchaseRequestId/resolve` (commit `1d881c5`, ISSUE-003 fix). This endpoint is now reachable.
**Response 200:** `{ success, message, data }`
## Evidence and messages ## Evidence and messages
@@ -115,7 +163,7 @@ Direct messages between disputants and the admin moderator are handled via a ded
## Real-time ## Real-time
Dispute mutations emit notifications via `POST /api/notifications` which delivers `new-notification` socket events to each participant's `user-<userId>` room. See [[Socket Events]] for payload shape. > ⚠️ All socket events from `DisputeService` are currently **TODO stubs** — no real-time events fire from dispute mutations. Dispute notifications are delivered only via `POST /api/notifications`, which in turn emits `new-notification` to the relevant `user-<userId>` room. See [[Socket Events]] for payload shape.
## Related ## Related

View File

@@ -35,7 +35,7 @@ Uncaught errors are formatted by [`shared/middleware/errorHandler.ts`](../../bac
} }
``` ```
Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy paths, `/api/payment/decentralized/*`, parts of `/api/payment/shkeeper/*`) return ad-hoc shapes such as `{ "error": "..." }` or `{ "success": false, "message": "..." }`. Treat any non-`2xx` response as an error and read whichever of `error` / `message` is present. Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy paths, and `/api/payment/decentralized/*`) return ad-hoc shapes such as `{ "error": "..." }` or `{ "success": false, "message": "..." }`. Treat any non-`2xx` response as an error and read whichever of `error` / `message` is present.
## HTTP status mapping ## HTTP status mapping
@@ -43,7 +43,7 @@ Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy path
| --- | --- | --- | | --- | --- | --- |
| `200 OK` | Successful read or mutation | Most `GET`s, idempotent `PUT`s/`PATCH`s | | `200 OK` | Successful read or mutation | Most `GET`s, idempotent `PUT`s/`PATCH`s |
| `201 Created` | Resource created | `POST /api/marketplace/purchase-requests`, `POST /api/auth/register` (when user created), `POST /api/marketplace/reviews` | | `201 Created` | Resource created | `POST /api/marketplace/purchase-requests`, `POST /api/auth/register` (when user created), `POST /api/marketplace/reviews` |
| `202 Accepted` | Async accepted (provider webhooks) | SHKeeper webhook acknowledgement | | `202 Accepted` | Async accepted (provider webhooks) | Request Network webhook accepted while safety checks are pending |
| `204 No Content` | Mutations with no body to return | Rare — most endpoints return the updated object | | `204 No Content` | Mutations with no body to return | Rare — most endpoints return the updated object |
| `400 Bad Request` | Validation failure, malformed input | `express-validator` errors, bad MongoIds, missing fields | | `400 Bad Request` | Validation failure, malformed input | `express-validator` errors, bad MongoIds, missing fields |
| `401 Unauthorized` | Missing or invalid JWT | `Access token required`, `Invalid or expired token` | | `401 Unauthorized` | Missing or invalid JWT | `Access token required`, `Invalid or expired token` |
@@ -53,7 +53,7 @@ Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy path
| `423 Locked` | Account temporarily locked | After repeated failed logins (Redis-tracked) | | `423 Locked` | Account temporarily locked | After repeated failed logins (Redis-tracked) |
| `429 Too Many Requests` | Rate limit hit | Currently issued only by per-feature Redis limits (auth / AI); global limiter is disabled | | `429 Too Many Requests` | Rate limit hit | Currently issued only by per-feature Redis limits (auth / AI); global limiter is disabled |
| `500 Internal Server Error` | Unhandled exception | Caught by `errorHandler`; included stack trace in dev | | `500 Internal Server Error` | Unhandled exception | Caught by `errorHandler`; included stack trace in dev |
| `502 Bad Gateway` | Upstream provider failure | OpenAI / SHKeeper unreachable | | `502 Bad Gateway` | Upstream provider failure | OpenAI / Request Network unreachable |
## Application error codes ## Application error codes
@@ -89,11 +89,10 @@ Handled in `errorHandler`:
| Provider | Endpoint | Status on success | Status on signature mismatch | | Provider | Endpoint | Status on success | Status on signature mismatch |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| SHKeeper pay-in | `POST /api/payment/shkeeper/webhook` | 200 `{ success: true }` | 401 `{ success: false }` (then ignored) | | Request Network pay-in | `POST /api/payment/request-network/webhook` | 200 `{ success: true }` or 202 while safety checks are pending | 401 `{ success: false }` |
| SHKeeper payout | `POST /api/payment/shkeeper/payout/webhook` | 200 / 400 with `{ success, message, data }` | 400 |
| Generic payment callback | `POST /api/payment/callback` | 200 `{ success: true, message }` | 400 | | Generic payment callback | `POST /api/payment/callback` | 200 `{ success: true, message }` | 400 |
If a webhook is acknowledged with non-2xx, the provider re-delivers (SHKeeper retries every 60 seconds). If a webhook is acknowledged with non-2xx, the provider may re-deliver. Persisting delivery evidence and replay support is a launch-hardening item in [[Request Network Integration Constraints]].
## Client guidance ## Client guidance

View File

@@ -5,6 +5,8 @@ tags: [api, marketplace, reference]
# Marketplace API # Marketplace API
> **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))
All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`: All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`:
- New controller-pattern routes: [`backend/src/services/marketplace/controllerRoutes.ts`](../../backend/src/services/marketplace/controllerRoutes.ts) (`marketplaceControllerRouter`) - New controller-pattern routes: [`backend/src/services/marketplace/controllerRoutes.ts`](../../backend/src/services/marketplace/controllerRoutes.ts) (`marketplaceControllerRouter`)
@@ -69,8 +71,8 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
size?: string; size?: string;
color?: string; color?: string;
quantity?: number; // default 1 quantity?: number; // default 1
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" }; budget?: { min?: number; max?: number; currency: "USDT" | "USDC" }; // restricted to escrow-compatible stablecoins (commit d52feb7)
urgency?: "low" | "medium" | "high"; urgency?: "low" | "medium" | "high" | "urgent";
deliveryInfo?: { deliveryInfo?: {
deliveryType: "physical" | "online"; deliveryType: "physical" | "online";
addressId?: string; // when physical addressId?: string; // when physical
@@ -96,6 +98,16 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** `status`, `categoryId`, `urgency`, `search`, `page`, `limit`, `sortBy`, `sortOrder` **Query params:** `status`, `categoryId`, `urgency`, `search`, `page`, `limit`, `sortBy`, `sortOrder`
> **Note:** Use query params on this endpoint for filtering/searching. The separate search and stats endpoints documented in earlier versions do not exist — see below.
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/purchase-requests/search
This endpoint does not exist. Use query params (`search`, `status`, `categoryId`, etc.) on `GET /api/marketplace/purchase-requests` instead.
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/purchase-requests/stats
This endpoint does not exist in the backend.
### GET /api/marketplace/purchase-requests/my ### GET /api/marketplace/purchase-requests/my
**Description:** Shortcut for the caller's own purchase requests. **Description:** Shortcut for the caller's own purchase requests.
@@ -112,6 +124,8 @@ The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequ
**Description:** Buyer edits draft / pending request fields. **Description:** Buyer edits draft / pending request fields.
**Auth required:** Bearer JWT (owner) **Auth required:** Bearer JWT (owner)
> ⚠️ **KNOWN BUG:** The frontend sends `PUT` but the backend registers `PATCH`. Requests from clients using `PUT` will receive `404`. Use `PATCH`.
### PATCH /api/marketplace/purchase-requests/:id/status ### PATCH /api/marketplace/purchase-requests/:id/status
**Description:** Transition the request status (`draft``pending``payment``processing``delivery``delivered``seller_paid``completed`, or `cancelled`). **Description:** Transition the request status (`draft``pending``payment``processing``delivery``delivered``seller_paid``completed`, or `cancelled`).
@@ -213,14 +227,19 @@ Six-digit codes the buyer hands to the seller at handover. Backed by `deliverySe
Model: [[SellerOffer]]. Model: [[SellerOffer]].
Valid `status` values: `pending | accepted | rejected | withdrawn`
> **Note:** The status value `active` does not exist on SellerOffer. Earlier docs were incorrect.
### POST /api/marketplace/purchase-requests/:id/offers ### POST /api/marketplace/purchase-requests/:id/offers
**Description:** Submit an offer against a purchase request. **Description:** Submit an offer against the purchase request identified by `:id` in the path. The purchase request must be in `pending`, `received_offers`, or `active` status.
**Auth required:** Bearer JWT (seller) **Auth required:** Bearer JWT (seller)
**Path param:** `:id` — the `purchaseRequestId` (not a body field)
**Request body:** **Request body:**
```ts ```ts
{ {
price: { amount: number; currency: "USD" | "EUR" | "IRR" }; price: { amount: number; currency: "USDT" }; // USDT only for escrow MVP
deliveryEstimate: { days: number; note?: string }; deliveryEstimate: { days: number; note?: string };
notes?: string; notes?: string;
attachments?: string[]; attachments?: string[];
@@ -229,6 +248,8 @@ Model: [[SellerOffer]].
**Response 201:** `{ success, data: { offer } }` **Response 201:** `{ success, data: { offer } }`
**Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`. **Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`.
> **Note:** Currency is locked to `USDT` for the escrow MVP (commit 3aaa2fe). The frontend `CURRENCY_SYMBOLS` map in `src/sections/request/constants.ts` exposes only `USDT`.
### PUT /api/marketplace/purchase-requests/:id/offers (legacy) ### PUT /api/marketplace/purchase-requests/:id/offers (legacy)
**Description:** Older offer-update endpoint kept for compatibility. **Description:** Older offer-update endpoint kept for compatibility.
@@ -248,11 +269,24 @@ Model: [[SellerOffer]].
**Description:** Fetch a specific seller's offer on a request. **Description:** Fetch a specific seller's offer on a request.
**Auth required:** No **Auth required:** No
### ⚠️ NOT IMPLEMENTED: GET /api/marketplace/offers/request/:requestId
This endpoint does not exist. Use `GET /api/marketplace/purchase-requests/:id/offers` instead.
### GET /api/marketplace/offers/seller/:sellerId
**Description:** Returns all offers submitted by the given seller, across all purchase requests. Used by the Offer Management dashboard page (`/dashboard/seller/marketplace/offers`).
**Auth required:** Bearer JWT (seller, own `:sellerId` only)
**Response 200:** `{ data: [SellerOffer, ...] }`
**Frontend action:** `getSellerOffers(sellerId)` in `src/actions/marketplace.ts` (added commit 240a668)
### PATCH /api/marketplace/offers/:id ### PATCH /api/marketplace/offers/:id
**Description:** Seller edits their pending offer (price, delivery estimate, notes). **Description:** Seller edits their pending offer (price, delivery estimate, notes).
**Auth required:** Bearer JWT (offer owner) **Auth required:** Bearer JWT (offer owner)
> ✅ **Fixed (commit 240a668):** The frontend `updateOffer` and `acceptOffer` actions now correctly send `PATCH`.
### DELETE /api/marketplace/offers/:id ### DELETE /api/marketplace/offers/:id
**Description:** Seller withdraws their offer. **Description:** Seller withdraws their offer.
@@ -260,9 +294,18 @@ Model: [[SellerOffer]].
### PUT /api/marketplace/offers/:id/status ### PUT /api/marketplace/offers/:id/status
**Description:** Direct status mutation (admin override / counter-offer states). **Description:** Direct status mutation (admin override / counter-offer states). This is also the correct way to withdraw an offer programmatically — send `{ status: 'withdrawn' }`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" | "countered" }` **Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" }`
### POST /api/marketplace/offers/:id/withdraw
**Description:** Seller withdraws their offer. Sets offer status to `withdrawn` using `sellerOfferService.withdrawOffer()`. Only the offer owner may call this.
**Auth required:** Bearer JWT (offer owner)
**Response 200:** `{ success: true, data: { /* updated offer */ } }`
**Errors:** `403` not the offer owner, `404` offer not found.
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added to `backend/src/services/marketplace/routes.ts` (commit `3e47713`).
### POST /api/marketplace/purchase-requests/:id/select-offer ### POST /api/marketplace/purchase-requests/:id/select-offer
@@ -270,7 +313,8 @@ Model: [[SellerOffer]].
**Auth required:** Bearer JWT (buyer) **Auth required:** Bearer JWT (buyer)
**Request body:** `{ offerId: string }` **Request body:** `{ offerId: string }`
**Side effects:** **Side effects:**
- Updates [[PurchaseRequest]] `selectedOfferId`, status moves toward `payment`. - Persists `selectedOfferId` on [[PurchaseRequest]] (commit `023255f` — previously this field was not saved, causing it to be lost). Status moves toward `payment`.
- Rejects all **losing** offers (sets their status to `rejected`) when payment is confirmed (commit `023255f`).
- Emits `seller-offer-update` to all sellers for the request. - Emits `seller-offer-update` to all sellers for the request.
### POST /api/marketplace/offers/:id/accept (legacy) ### POST /api/marketplace/offers/:id/accept (legacy)

View File

@@ -5,6 +5,8 @@ tags: [api, notification, reference]
# Notification API # Notification API
> **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))
Endpoints live under `/api/notifications/*`. Two routers are mounted: Endpoints live under `/api/notifications/*`. Two routers are mounted:
- New controller pattern: [`notificationControllerRoutes.ts`](../../backend/src/services/notification/notificationControllerRoutes.ts) (controller-backed, requires auth) - New controller pattern: [`notificationControllerRoutes.ts`](../../backend/src/services/notification/notificationControllerRoutes.ts) (controller-backed, requires auth)
@@ -12,7 +14,7 @@ Endpoints live under `/api/notifications/*`. Two routers are mounted:
Both routers are mounted at `/api`, so the paths collide; the controller router wins for the shared paths (it is mounted first). The legacy router is still used by background scripts and admin tools that have no JWT context. Both routers are mounted at `/api`, so the paths collide; the controller router wins for the shared paths (it is mounted first). The legacy router is still used by background scripts and admin tools that have no JWT context.
Model: [[Notification]]. Real-time delivery is via `new-notification` and `unread-count-update` Socket.IO events on `user-<userId>`. See [[Socket Events]]. Model: [[Notification]]. Notifications are **auto-deleted after 90 days**. Real-time delivery is via `new-notification` and `unread-count-update` Socket.IO events on `user-<userId>`. See [[Socket Events]].
## List ## List
@@ -47,6 +49,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Errors:** `404` not found, `403` not owner. **Errors:** `404` not found, `403` not owner.
> ⚠️ **KNOWN BUG:** The controller fetches only the 1 most-recent notification for the user and does an in-memory ID match. Any notification that is not the user's single latest will return `404` even if it exists and belongs to the user. Do not rely on this endpoint for fetching arbitrary notifications by id.
## Mutations ## Mutations
### PATCH /api/notifications/:id/read ### PATCH /api/notifications/:id/read
@@ -62,6 +66,8 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { "modifiedCount": 12 } }` **Response 200:** `{ "success": true, "data": { "modifiedCount": 12 } }`
> **Note:** Earlier versions of this documentation incorrectly listed this as `POST /api/notifications/read-all`. The correct path and method are `PATCH /notifications/mark-all-read`.
### PATCH /api/notifications/bulk/mark-read ### PATCH /api/notifications/bulk/mark-read
**Description:** Mark a list of notifications as read. **Description:** Mark a list of notifications as read.
@@ -99,10 +105,26 @@ Model: [[Notification]]. Real-time delivery is via `new-notification` and `unrea
**Response 201:** `{ success, data: { notification } }` **Response 201:** `{ success, data: { notification } }`
**Side effects:** Emits `new-notification` to `user-<userId>`; also increments unread count via `unread-count-update`. **Side effects:** Emits `new-notification` to `user-<userId>`; also increments unread count via `unread-count-update`.
## Real-time socket events
### `new-notification`
Emitted to `user-<userId>` when a new notification is created for that user.
### `unread-count-update`
Emitted to `user-<userId>` whenever the unread notification count changes (e.g. after marking one or all as read, or after a new notification arrives). This is the canonical cross-tab sync event.
> **Note:** Earlier docs referenced a `notification-read` socket event for cross-tab sync. That event does not exist. The real event is `unread-count-update`.
## Preferences ## Preferences
Notification preferences live on [[User]] (`preferences.notifications.email | sms | push`). They are read and written through the [[User API]] (`GET /api/user/profile`, `PUT /api/user/profile`). Notification preferences live on [[User]] (`preferences.notifications.email | sms | push`). They are read and written through the [[User API]] (`GET /api/user/profile`, `PUT /api/user/profile`).
## Data retention
Notifications are automatically deleted after **90 days**.
## Related ## Related
- [[Notification]] - [[Notification]]

View File

@@ -1,19 +1,24 @@
--- ---
title: Payment API title: Payment API
tags: [api, payment, reference, shkeeper] tags: [api, payment, reference, request-network, escrow]
--- ---
# Payment API # Payment API
The payment surface is split across four routers, all mounted under `/api/payment/*`: > **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
The payment surface is split across provider-neutral payment routers, Request Network checkout/webhook routes, derived-destination custody routes, and admin safety routes:
| Path prefix | File | Purpose | | Path prefix | File | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `/api/payment/*` | [`paymentControllerRoutes.ts`](../../backend/src/services/payment/paymentControllerRoutes.ts) | New controller pattern (CRUD + configuration) | | `/api/payment/*` | [`paymentControllerRoutes.ts`](../../backend/src/services/payment/paymentControllerRoutes.ts) | New controller pattern (CRUD + configuration) |
| `/api/payment/*` | [`paymentRoutes.ts`](../../backend/src/services/payment/paymentRoutes.ts) | Additional legacy endpoints (tx fetch, exports) | | `/api/payment/*` | [`paymentRoutes.ts`](../../backend/src/services/payment/paymentRoutes.ts) | Additional legacy endpoints (tx fetch, exports) |
| `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | DePay / Web3 confirmations | | `/api/payment/request-network/*` | [`requestNetwork/requestNetworkRoutes.ts`](../../backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts) | Request Network intent creation, in-house checkout payloads, webhook processing |
| `/api/payment/shkeeper/*` | [`shkeeper/shkeeperRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperRoutes.ts) | SHKeeper pay-in, webhook, release/refund | | `/api/payment/derived-destinations/*` | [`wallets/derivedDestinationRoutes.ts`](../../backend/src/services/payment/wallets/derivedDestinationRoutes.ts) | Derived destination inspection, balance checks, and sweeping |
| `/api/payment/shkeeper/payout*` | [`shkeeper/shkeeperPayoutRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts) | SHKeeper payouts to sellers | | `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | Legacy wallet-direct confirmations |
| `/api/payment/amn-scanner/*` | [`routes/amnScannerWebhookRoutes.ts`](../../backend/src/routes/amnScannerWebhookRoutes.ts) | AMN Pay Scanner webhook receiver |
| `/api/admin/rn/networks/*` | [`requestNetwork/networkRegistryRoutes.ts`](../../backend/src/services/payment/requestNetwork/networkRegistryRoutes.ts) | Request Network chain/token registry |
| `/api/admin/payments/awaiting-confirmation/*` | `awaitingConfirmationRoutes.ts` | Admin queue for payments waiting on confirmation/safety checks |
Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`. Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`.
@@ -21,7 +26,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/configuration ### POST /api/payment/configuration
**Description:** Returns the payment provider configuration the SHKeeper widget needs (accepted blockchains, escrow receiver address, redirect URLs, webhook URL). **Description:** Returns the active payment provider configuration, including Request Network settings, supported chain/token data, receiver/derived-destination context, and redirect/webhook URLs where applicable.
**Auth required:** No **Auth required:** No
**Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config) **Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config)
**Response 200:** **Response 200:**
@@ -29,7 +34,7 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
{ {
"accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }], "accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }],
"redirect": { "success": "...", "cancel": "..." }, "redirect": { "success": "...", "cancel": "..." },
"webhook": "https://.../api/payment/shkeeper/webhook" "webhook": "https://.../api/payment/request-network/webhook"
} }
``` ```
@@ -37,18 +42,18 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Lightweight health probe. **Description:** Lightweight health probe.
**Auth required:** No **Auth required:** No
**Response 200:** `{ success, message, endpoints: { shkeeper, decentralized, health } }` **Response 200:** `{ success, message, endpoints }`. Older builds may still list legacy endpoint names in this health payload; rely on `app.ts` mounts for the authoritative live surface.
### GET /api/payment/shkeeper/config ### GET /api/payment/shkeeper/config
**Description:** Same payload as `/configuration` but tailored for the SHKeeper-hosted widget; includes explicit CORS `*` headers. **Description:** Historical compatibility endpoint for the old SHKeeper-hosted widget. It is not part of the current Request Network checkout path.
**Auth required:** No **Auth required:** No
## Payment records (CRUD) ## Payment records (CRUD)
### POST /api/payment ### POST /api/payment
**Description:** Create a payment record (manual entry — usually the SHKeeper intent path is preferred). **Description:** Create a payment record manually. Normal buyer checkout should use `POST /api/payment/request-network/pay-in`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
@@ -86,15 +91,11 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### GET /api/payment/:id ### GET /api/payment/:id
**Description:** Fetch a payment by id. **Description:** Fetch a payment by id. For payments with `provider: 'request.network'` that are still `pending`, this endpoint also performs an **on-demand RN reconcile**: it queries the Request Network node live, and if RN reports the request as paid it immediately marks the payment `completed`, advances the purchase request to `processing`, persists `selectedOfferId`, and accepts the winning offer while rejecting all others. This reconcile path exists because RN webhooks cannot reach a local dev server and the reconcile cron is not started there; the same logic fires in production as a safety net.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Errors:** `404` not found. **Errors:** `404` not found.
### GET /api/payment/:id/debug > ⚠️ **NOT IMPLEMENTED:** `GET /payment/:id/status`, `POST /payment/:id/confirm`, and `DELETE /payment/:id` do not exist in the codebase. Do not call these paths.
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status.
**Auth required:** Bearer JWT
**Notes:** Intended for admin / development.
### GET /api/payment/user/:userId ### GET /api/payment/user/:userId
@@ -106,12 +107,16 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Aggregated counts and sums per status. **Description:** Aggregated counts and sums per status.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**⚠️ Known undercounting:** Only payments with status `'confirmed'` are counted as `successfulPayments`. Payments with status `'completed'` (the terminal state for SHKeeper and DePay) are **not** included in this count and are therefore under-reported.
### GET /api/payment/export / GET /api/payment/export/:userId ### GET /api/payment/export / GET /api/payment/export/:userId
**Description:** Export payments as `json` or `csv`. **Description:** Export payments as `json` or `csv`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** `format=json|csv` **Query params:** `format=json|csv`
**⚠️ Privilege gap:** The controller-pattern route for this endpoint has no admin guard. Any authenticated user (not just admins) can export payment data.
> ⚠️ **NOT IMPLEMENTED:** `/payment/history`, `/payment/methods`, `/payment/validate`, `/payment/transactions`, and `/payment/escrow/balance` do not exist. Do not call these paths.
### POST /api/payment/payments/cleanup-pending ### POST /api/payment/payments/cleanup-pending
@@ -122,15 +127,20 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/payments/:id/fetch-tx ### POST /api/payment/payments/:id/fetch-tx
**Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment. **Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Response 200:** `{ success, transactionHash, network, source, message }` **Response 200:** `{ success, transactionHash, network, source, message }`
### POST /api/payment/payments/auto-fetch-missing ### POST /api/payment/payments/auto-fetch-missing
**Description:** Batch tx-hash backfill across the database. **Description:** Batch tx-hash backfill across the database.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
**Request body:** `{ limit?: number }` (default 10) **Request body:** `{ limit?: number }` (default 10)
### GET /api/payment/payments/:id/debug
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status. Intended for admin / development.
**Auth required:** Bearer JWT (admin) — `authenticateToken` + `authorizeRoles('admin')` added in commit `1d881c5` (ISSUE-005 fix).
### POST /api/payment/callback ### POST /api/payment/callback
**Description:** Generic payment callback (called by the older client SDK). **Description:** Generic payment callback (called by the older client SDK).
@@ -139,10 +149,74 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
### POST /api/payment/verify ### POST /api/payment/verify
**Description:** Frontend verification endpoint used by the Web3 flow. Confirms a payment and updates the related [[PurchaseRequest]]. **Description:** Legacy frontend verification endpoint used by the wallet-direct Web3 flow. Confirms a payment and updates the related [[PurchaseRequest]].
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
## SHKeeper - Pay-in ## Request Network - Pay-in
### 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.
**Auth required:** Bearer JWT (buyer)
**Request body:**
```ts
{
purchaseRequestId: string;
sellerOfferId: string;
sellerId: string;
amount: number;
token?: string; // default "USDT" or REQUEST_NETWORK_PAYMENT_CURRENCY
network?: string; // default REQUEST_NETWORK_NETWORK or "bsc"
metadata?: Record<string, unknown>;
}
```
**Response 200:** `{ success: true, data: { paymentId, paymentUrl, providerPaymentId, raw, ... } }`
### 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.
**Auth required:** Bearer JWT (buyer who owns the payment)
### POST /api/payment/request-network/webhook
**Description:** Request Network posts settlement updates here. The route verifies `x-request-network-signature` over the raw body, deduplicates delivery IDs, evaluates the Transaction Safety Provider, and coordinates the payment/ledger update.
**Auth required:** No (signature-protected)
**Response:** `200` when processed or duplicate; `202` when accepted but safety checks are pending; `401` for invalid signature.
> [!note] RN payout/release/refund routes
> `POST /api/payment/request-network/:paymentId/payout/initiate`, `POST /api/payment/request-network/:paymentId/payout/confirm`, `POST /api/payment/request-network/:paymentId/release/confirm`, and `POST /api/payment/request-network/:paymentId/refund/confirm` are registered in `requestNetworkRoutes.ts` but are stub-level implementations. They accept the request and return a 200 but do not yet drive the ledger-gated release/refund orchestration. Use `POST /api/payment/:id/release` and `POST /api/payment/:id/refund` for actual escrow releases.
## AMN Pay Scanner - Pay-in
AMN Pay Scanner is a custom in-house blockchain scanner that replaces the hosted Request Network page for payment monitoring. It speaks the same `PaymentProviderAdapter` interface as the RN adapter.
### POST /api/payment/amn-scanner/webhook
**Description:** AMN Pay Scanner posts settlement confirmations here. The route verifies a `webhookSecret`-based HMAC signature, then runs the Transaction Safety Provider and `PaymentCoordinator` pipeline identical to the RN webhook path.
**Auth required:** No (signature-protected via `AMN_SCANNER_WEBHOOK_SECRET`)
**Request body:** `{ intentId, status, transactionHash?, chainId?, ... }` — scanner-specific envelope
**Response:** `200` processed; `401` bad signature; `400` missing `intentId` or unknown format; `404` payment not found.
**Side effects:** Same as the RN webhook — updates [[Payment]], advances [[PurchaseRequest]], accepts/rejects offers, emits socket events when safety checks pass.
> [!note] Provider value
> Payments created via the AMN Pay Scanner have `provider: 'amn.scanner'` in the database. This is distinct from `request.network` and `shkeeper`.
### GET /api/admin/scanner/status
**Description:** Proxies to `AMN_SCANNER_URL/scanner/status` and returns the scanner's internal state.
**Auth required:** Bearer JWT (`admin`) — `authenticateToken` + `authorizeRoles('admin')` are now applied (the previously documented security gap — unauthenticated access — has been fixed in commit `1d881c5`).
**Response 200:** Scanner status JSON forwarded from the upstream service.
### POST /api/admin/scanner/webhooks/retry
**Description:** Triggers a manual retry of failed/pending scanner webhooks.
**Auth required:** Bearer JWT (`admin`)
**Request body:** `{ intentId?: string }` — omit to retry all pending.
## Legacy SHKeeper - Pay-in
> [!warning] Historical route family
> The current `app.ts` mounts Request Network routes, not `services/payment/shkeeper/*`. Keep this section only for legacy record migration and old operational context.
### POST /api/payment/shkeeper/intents ### POST /api/payment/shkeeper/intents
@@ -182,11 +256,13 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Body:** The SHKeeper callback envelope (`external_id`, `crypto`, `addr`, `fiat`, `balance_fiat`, `balance_crypto`, `paid`, `status`, `transactions[]`). **Body:** The SHKeeper callback envelope (`external_id`, `crypto`, `addr`, `fiat`, `balance_fiat`, `balance_crypto`, `paid`, `status`, `transactions[]`).
**Response 200:** `{ success: true }` **Response 200:** `{ success: true }`
**Side effects:** **Side effects:**
- Updates the matching [[Payment]] to `completed` (`OVERPAID` and `PAID` both count). - Updates the matching [[Payment]] to `completed` (`OVERPAID` and `PAID` both count). Note: `'completed'` is the terminal state for SHKeeper payments but is **not** counted as `successfulPayments` in `GET /api/payment/stats`.
- Releases or rejects [[SellerOffer]] siblings (the chosen offer becomes `accepted`, others `rejected`). - Releases or rejects [[SellerOffer]] siblings (the chosen offer becomes `accepted`, others `rejected`).
- Updates [[PurchaseRequest]] status to `payment` / `processing`. - Updates [[PurchaseRequest]] status to `payment` / `processing`.
- Emits `seller-offer-update` to each affected seller room and `purchase-request-update` to the request room. - Emits `seller-offer-update` to each affected seller room and `purchase-request-update` to the request room.
> ⚠️ **NOT IMPLEMENTED:** `GET /api/payment/shkeeper/status/:paymentId` does not exist. SHKeeper payment status is delivered via socket events only — there is no HTTP polling endpoint.
### POST /api/payment/shkeeper/confirm-transaction ### POST /api/payment/shkeeper/confirm-transaction
**Description:** Manual fallback when the webhook misses — the frontend calls this after the buyer signs the EVM transaction directly. Coordinated through `PaymentCoordinator` to avoid double updates. **Description:** Manual fallback when the webhook misses — the frontend calls this after the buyer signs the EVM transaction directly. Coordinated through `PaymentCoordinator` to avoid double updates.
@@ -230,37 +306,39 @@ Core model: [[Payment]]. Coordination logic to avoid race conditions when multip
**Description:** Counters for webhook deliveries (success / failure / duplicates). **Description:** Counters for webhook deliveries (success / failure / duplicates).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
## SHKeeper - Release / Refund (escrow) ## Legacy SHKeeper - Release / Refund (escrow)
These build an admin-signed transaction off-chain and require a follow-up confirm with the broadcast tx hash. Source: `shkeeperService.buildAdminSignedTxPayload` and `confirmAdminTx`. These build an admin-signed transaction off-chain and require a follow-up confirm with the broadcast tx hash. Source: `shkeeperService.buildAdminSignedTxPayload` and `confirmAdminTx`.
### POST /api/payment/shkeeper/:id/release **⚠️ Path correction:** The `/shkeeper/` segment is NOT present in the actual release/refund routes. The correct paths are under `/api/payment/:id/…` (not `/api/payment/shkeeper/:id/…`).
### POST /api/payment/:id/release
**Description:** Prepares the admin-signed payload to release escrow to the seller. Returns the raw payload — the admin client signs and broadcasts. **Description:** Prepares the admin-signed payload to release escrow to the seller. Returns the raw payload — the admin client signs and broadcasts.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Response 200:** `{ success: true, data: { /* tx payload */ } }` **Response 200:** `{ success: true, data: { /* tx payload */ } }`
### POST /api/payment/shkeeper/:id/release/confirm ### POST /api/payment/:id/release/confirm
**Description:** Records the broadcast transaction hash for the release; marks the payment as released, updates [[PurchaseRequest]] to `seller_paid` and emits `purchase-request-update` (`type: payment_released`). **Description:** Records the broadcast transaction hash for the release; marks the payment as released, updates [[PurchaseRequest]] to `seller_paid` and emits `purchase-request-update` (`type: payment_released`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ txHash: string }` **Request body:** `{ txHash: string }`
**Errors:** `400` missing `txHash`. **Errors:** `400` missing `txHash`.
### POST /api/payment/shkeeper/:id/refund ### POST /api/payment/:id/refund
**Description:** Mirror of release, but returns the escrow to the buyer. **Description:** Mirror of release, but returns the escrow to the buyer.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
### POST /api/payment/shkeeper/:id/refund/confirm ### POST /api/payment/:id/refund/confirm
**Description:** Records the refund tx hash; emits `purchase-request-update` (`type: payment_refunded`). **Description:** Records the refund tx hash; emits `purchase-request-update` (`type: payment_refunded`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ txHash: string }` **Request body:** `{ txHash: string }`
## SHKeeper - Payouts ## Legacy SHKeeper - Payouts
Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot wallet). Historical payouts were SHKeeper-side outbound transfers. Current routine releases should use ledger-gated release/refund orchestration instead.
### POST /api/payment/shkeeper/payout ### POST /api/payment/shkeeper/payout
@@ -296,7 +374,9 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
**Auth required:** No (signature checked) **Auth required:** No (signature checked)
**Response 200/400:** `{ success, message, data }` **Response 200/400:** `{ success, message, data }`
## DePay / Web3 (decentralized) ## Legacy Web3 Wallet-Direct (DePay)
> ⚠️ **NOT IMPLEMENTED:** `POST /payment/depay/intents` (`createDePayIntent`) does not exist in the codebase.
### POST /api/payment/decentralized/save ### POST /api/payment/decentralized/save
@@ -341,7 +421,7 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
### POST /api/payment/decentralized/verify/:paymentId ### POST /api/payment/decentralized/verify/:paymentId
**Description:** Re-verifies a single decentralized payment against the chain. **Description:** Re-verifies a single decentralized payment against the chain. `paymentId` is a **path parameter** as shown.
**Auth required:** Bearer JWT (owner or admin) **Auth required:** Bearer JWT (owner or admin)
### POST /api/payment/decentralized/verify-all-pending ### POST /api/payment/decentralized/verify-all-pending
@@ -351,7 +431,7 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
### POST /api/payment/decentralized/admin-payout ### POST /api/payment/decentralized/admin-payout
**Description:** Pay a seller directly from an admin hot wallet (no SHKeeper). **Description:** Pay a seller directly from an admin hot wallet. This bypasses the newer ledger-gated release/refund orchestration and should not be used for routine releases.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** **Request body:**
```ts ```ts
@@ -365,24 +445,240 @@ Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot w
``` ```
**Response 200:** `{ success, data: { /* payout receipt */ } }` **Response 200:** `{ success, data: { /* payout receipt */ } }`
## Derived Destinations
These endpoints manage per-(buyer, sellerOffer) ephemeral payment addresses.
| Method | Route | Auth | Purpose |
|--------|-------|------|---------|
| `GET` | `/api/payment/derived-destinations` | Admin | List destinations with filters/pagination |
| `POST` | `/api/payment/derived-destinations/sweep` | Admin | Sweep **all** active destinations |
| `POST` | `/api/payment/derived-destinations/:id/sweep` | Admin | Sweep **one** destination |
| `POST` | `/api/payment/derived-destinations/:id/balance` | Admin | Refresh on-chain balance for one destination |
| `GET` | `/api/payment/derived-destinations/config/health` | Admin | Verify xpub and sweep signer config |
| `POST` | `/api/payment/derived-destinations/cron/start` | Admin | Start the sweep cron |
| `POST` | `/api/payment/derived-destinations/cron/stop` | Admin | Stop the sweep cron |
| `GET` | `/api/payment/derived-destinations/cron/status` | Admin | Check if sweep cron is running |
### `GET /api/payment/derived-destinations`
Query params: `buyerId`, `sellerOfferId`, `status` (`active|swept`), `address`, `chainId`, `page`, `limit`.
**Response 200:**
```json
{
"success": true,
"data": {
"destinations": [
{
"_id": "...",
"buyerId": "...",
"sellerOfferId": "...",
"address": "0x...",
"derivationPath": "m/44'/60'/0'/0/5",
"derivationIndex": 5,
"chainId": 56,
"status": "active",
"balance": "1000000000",
"sweepCount": 0,
"totalSwept": "0",
"createdAt": "..."
}
],
"pagination": { "page": 1, "limit": 20, "total": 42 }
}
}
```
### `POST /api/payment/derived-destinations/sweep`
Body: `{ chainId?: number, tokenSymbol?: string, minSweepAmount?: string }` — all optional.
**Response 200:** `{ success: true, data: { results: SweepResult[] } }`
Each `SweepResult`:
```ts
{
destinationId: string;
address: string;
status: 'success' | 'error' | 'skipped';
txHash?: string;
amount?: string;
error?: string;
}
```
### `POST /api/payment/derived-destinations/:id/sweep`
Same result shape as above, but for a single destination.
### `GET /api/payment/derived-destinations/config/health`
**Response 200:**
```json
{
"success": true,
"data": {
"xpubValid": true,
"xpubFingerprint": "0xabcd...",
"signerType": "build-only",
"signerHealthy": true,
"chainId": 56,
"masterWallet": "0x..."
}
}
```
## Frontend PaymentProvider type
`src/types/payment.ts` defines `PaymentProvider` as:
```ts
type PaymentProvider = 'request.network' | 'test' | 'other';
```
> ⚠️ **Type gap (M37):** Despite both SHKeeper and the legacy wallet-direct (DePay/decentralized) flows being active in production, neither `'shkeeper'` nor `'decentralized'` appears in this union. Any frontend code that branches on `provider` will treat both as `'other'` or fall through a switch default. The backend stores the literal strings `"shkeeper"` and `"decentralized"` in the database; the mismatch exists only in the frontend type definition.
## Status model ## Status model
[[Payment]] uses the statuses below across all providers: [[Payment]] uses the statuses below across all providers:
- `pending` - intent created, awaiting on-chain settlement - `pending` - intent created, awaiting on-chain settlement
- `processing` - settlement seen, awaiting confirmations - `processing` - settlement seen, awaiting confirmations
- `confirmed` - fully credited (intermediate; sometimes skipped) - `confirmed` - fully credited (intermediate; sometimes skipped). **Note:** this is the only status counted as `successfulPayments` in `GET /api/payment/stats`.
- `completed` - confirmed, escrow funded - `completed` - confirmed, escrow funded. Terminal state for SHKeeper and DePay. **Not** counted in `successfulPayments` stats — see stats undercounting note above.
- `failed` - intentionally failed (expired, declined, refused) - `failed` - intentionally failed (expired, declined, refused)
- `cancelled` - cancelled by user/admin - `cancelled` - cancelled by user/admin
- `released` - escrow released to seller (`shkeeper` flow) - `released` - escrow released to seller through the release/refund orchestration and custody signer
- `refunded` - escrow returned to buyer - `refunded` - escrow returned to buyer
Escrow state (`escrowState`): `unfunded``funded``released` | `refunded`. Escrow state (`escrowState`): `unfunded``funded``released` | `refunded`.
## Confirmation thresholds (admin)
### `GET /api/admin/settings/confirmation-thresholds`
**Auth:** Admin only
**Response 200:**
```json
{
"success": true,
"data": [
{ "chainId": 56, "threshold": 12, "source": "default" },
{ "chainId": 1, "threshold": 3, "source": "config" }
]
}
```
### `PATCH /api/admin/settings/confirmation-thresholds/:chainId`
**Auth:** Admin only
**Body:** `{ "threshold": 3 }`
**Description:** Updates the runtime confirmation threshold for a chain. The in-memory cache is invalidated immediately so the next `TransactionSafetyProvider` evaluation uses the new value.
**Response 200:**
```json
{
"success": true,
"message": "Confirmation threshold for chain 56 updated to 3",
"data": { "chainId": 56, "threshold": 3 }
}
```
### `GET /api/admin/settings/confirmation-thresholds/history`
**Auth:** Admin only
**Description:** Returns paginated audit log of past confirmation threshold changes. Each entry records the admin who made the change, old/new threshold values, chain ID, and timestamp. Backed by the `ConfigSettingHistory` Mongoose model added in commit `27fb15a` (task #9).
**Response 200:** `{ success: true, data: [{ chainId, oldThreshold, newThreshold, changedBy, changedAt }] }`
> **Note:** This endpoint was previously documented as NOT IMPLEMENTED. It was added in commit `27fb15a` and is now live at `/api/admin/settings/confirmation-thresholds/history`.
## Payments awaiting confirmation (admin)
### `GET /api/admin/payments/awaiting-confirmation`
**Auth:** Admin only
**Query:** `page`, `limit`, `chainId` (optional)
**Description:** Lists payments that have an on-chain transaction hash but have not yet reached sufficient confirmations (i.e. `metadata.transactionSafety.status === 'pending'` or `escrowState` is not funded/released/refunded).
**Response 200:**
```json
{
"success": true,
"data": [
{
"_id": "...",
"paymentId": "...",
"status": "pending",
"amount": { "amount": 12.5, "currency": "USDC" },
"blockchain": { "network": "bsc", "transactionHash": "0x...", "confirmations": 3 },
"metadata": { "transactionSafety": { "status": "pending", "checks": [...] } },
"createdAt": "2026-05-28T..."
}
],
"pagination": { "page": 1, "limit": 25, "total": 4, "totalPages": 1 }
}
```
## Request Network multichain registry (admin)
### `GET /api/admin/rn/networks`
**Auth:** Admin only
**Response 200:**
```json
{
"success": true,
"data": [
{
"chainId": 56,
"name": "BNB Smart Chain",
"shortName": "BSC",
"rpcUrl": "https://bsc-dataseed.binance.org/",
"publicRpcUrl": "https://bsc-rpc.publicnode.com",
"blockExplorer": "https://bscscan.com",
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"nativeCurrency": { "name": "BNB", "symbol": "BNB", "decimals": 18 },
"confirmationThreshold": 12,
"tokens": [
{ "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "symbol": "USDC", "decimals": 18, "name": "Binance-Peg USD Coin" },
{ "address": "0x55d398326f99059ff775485246999027b3197955", "symbol": "USDT", "decimals": 18, "name": "Binance-Peg BSC-USD" }
]
}
],
"meta": { "chainCount": 5, "tokenCount": 10 }
}
```
### `POST /api/admin/rn/networks/reload`
**Auth:** Admin only
**Description:** Reloads the chain and token registries from disk (`supportedChains.json` and `tokens.json`). Returns `{ success: true, message: 'Registry reloaded from disk' }`. Use this after updating the JSON files without restarting the server.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
### `POST /api/admin/rn/networks/probe/:chainId`
**Auth:** Admin only
**Description:** Performs a live on-chain probe for the specified chain: verifies RPC reachability, checks for deployed proxy contract bytecode (`eth_getCode`), and test-calls the proxy with a dummy payload to confirm it reverts meaningfully. Returns:
```json
{
"success": true,
"data": {
"chainId": 56,
"reachable": true,
"hasCode": true,
"callValid": true,
"blockNumber": "0x...",
"latencyMs": 120
}
}
```
Errors: `400` if `chainId` is not a number; `404` if the chain is not in the registry; `500` on RPC failure.
> **Note:** This route is now implemented (commit `5681abf`). Earlier docs incorrectly listed it as not implemented.
## Related ## Related
- [[Payment Flow]]
- [[Escrow Flow]] - [[Escrow Flow]]
- [[SHKeeper Webhook Flow]] - [[Request Network Integration Constraints]]
- [[Payout Flow]]
- [[Socket Events]] - [[Socket Events]]

View File

@@ -5,10 +5,14 @@ tags: [api, points, reference]
# Points API # Points API
> **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))
Endpoints live under `/api/points/*`. The router is [`backend/src/routes/pointsRoutes.ts`](../../backend/src/routes/pointsRoutes.ts), delegating to [`PointsController`](../../backend/src/controllers/pointsController.ts) and `PointsService` ([`backend/src/services/points/PointsService.ts`](../../backend/src/services/points/PointsService.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`. Endpoints live under `/api/points/*`. The router is [`backend/src/routes/pointsRoutes.ts`](../../backend/src/routes/pointsRoutes.ts), delegating to [`PointsController`](../../backend/src/controllers/pointsController.ts) and `PointsService` ([`backend/src/services/points/PointsService.ts`](../../backend/src/services/points/PointsService.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`.
Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically by the platform (referral signup, successful purchases, reviews) and can be redeemed for discounts or marketplace credits. Levels progress as the user's lifetime points cross configured thresholds. Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically by the platform (referral signup, successful purchases, reviews) and can be redeemed for discounts or marketplace credits. Levels progress as the user's lifetime points cross configured thresholds.
> **Note on `PointTransaction.type`** — Valid values are `earn | spend | expire` only. There is **no** `refund` type; a financial refund does not create a points transaction.
## Balance and history ## Balance and history
### GET /api/points/my-points ### GET /api/points/my-points
@@ -36,7 +40,7 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Query params:** **Query params:**
- `page` (default 1), `limit` (default 20) - `page` (default 1), `limit` (default 20)
- `type` (`earn` | `redeem` | `referral` | `purchase` | `review` | `admin_grant` | `admin_deduct`) - `type` (`earn` | `spend` | `expire` | `admin_grant` | `admin_deduct`) — note: `redeem`, `referral`, `purchase`, `review` are **not** valid filter values
- `from` / `to` (ISO dates) - `from` / `to` (ISO dates)
**Response 200:** **Response 200:**
```json ```json
@@ -49,18 +53,24 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
} }
``` ```
> ⚠️ **Missing frontend pages** — `/dashboard/points/transactions`, `/dashboard/points/referrals`, and `/dashboard/points/levels` are referenced in documentation but **do not exist** in the frontend. Users cannot access these views through the UI.
### GET /api/points/referrals ### GET /api/points/referrals
**Description:** Users referred by the caller plus the points earned from each. **Description:** Users referred by the caller plus the points earned from each.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }` **Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }`
> ⚠️ **Missing frontend page** — `/dashboard/points/referrals` does not exist.
### GET /api/points/levels ### GET /api/points/levels
**Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page. **Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page.
**Auth required:** Bearer JWT (but data is non-sensitive) **Auth required:** Bearer JWT (but data is non-sensitive)
**Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }` **Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }`
> ⚠️ **Missing frontend page** — `/dashboard/points/levels` does not exist.
### GET /api/points/leaderboard ### GET /api/points/leaderboard
**Description:** Top referrers by referral count and points earned. Used for community displays. **Description:** Top referrers by referral count and points earned. Used for community displays.
@@ -68,18 +78,19 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
**Query params:** `limit` (default 10), `period` (`all` | `month` | `week`) **Query params:** `limit` (default 10), `period` (`all` | `month` | `week`)
**Response 200:** `{ success, data: { entries: [{ userId, name, avatar, referrals, pointsEarned }] } }` **Response 200:** `{ success, data: { entries: [{ userId, name, avatar, referrals, pointsEarned }] } }`
> ⚠️ **Known limitation** — The `period` query parameter (`all` | `month` | `week`) is **silently ignored** by the backend. The leaderboard always returns all-time results regardless of the value passed.
## Mutations ## Mutations
### POST /api/points/redeem ### POST /api/points/redeem
**Description:** Redeem points for a marketplace credit / discount. Server validates available balance and configured redemption rate. **Description:** Redeem points against an in-progress purchase. Server validates available balance and configured redemption rate.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
{ {
amount: number; // points to redeem pointsToUse: number; // points to redeem
purpose?: "wallet_credit" | "discount_code"; purchaseRequestId: string; // the in-progress purchase to apply the discount to
purchaseRequestId?: string; // when applying to an in-progress purchase
} }
``` ```
**Response 200:** **Response 200:**
@@ -88,8 +99,8 @@ Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically b
"success": true, "success": true,
"data": { "data": {
"transaction": { /* PointTransaction */ }, "transaction": { /* PointTransaction */ },
"redemption": { "creditAmount": 3.20, "currency": "USD", "code": "DISC-..." }, "discount": { "creditAmount": 3.20, "currency": "USD" },
"newBalance": 0 "remainingPoints": 0
} }
} }
``` ```
@@ -127,7 +138,11 @@ The short link redirect (`GET /r/:code`) is mounted at the app root in `app.ts`
`PointsService` emits Socket.IO events on level-up and referral rewards: `PointsService` emits Socket.IO events on level-up and referral rewards:
- `level-up` on `user-<userId>` when a transaction crosses a level threshold. - `level-up` on `user-<userId>` when a transaction crosses a level threshold.
- `referral-reward` on `user-<referrerId>` when a referred user triggers a reward. - `referral-reward` on `user-<referrerId>` when a referred user triggers a reward. This fires only when the referred user's purchase reaches **`'completed'`** status — it does **not** fire on `'delivered'`.
`authController` (not `PointsService`) emits:
- `referral-signup` on `user-<referrerId>` when a referred user completes registration.
See [[Socket Events]] for payload shape. See [[Socket Events]] for payload shape.

View File

@@ -0,0 +1,249 @@
---
title: Scanner API
tags: [api, scanner, payment]
created: 2026-05-30
---
# Scanner API
HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`.
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>` when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
Base URL (dev): `http://localhost:8080`
---
## Authentication
```
Authorization: Bearer <SCANNER_API_KEY>
```
- Uses constant-time comparison to prevent timing attacks.
- Returns `401 {"error":"unauthorized"}` on failure.
- `/health` is explicitly excluded from auth — always open.
---
## POST /intents
Register a new payment intent. The scanner will watch the specified chain for a matching transfer and call back to `callbackUrl` when confirmed.
**Request body** (`application/json`):
| Field | Type | Required | Notes |
|---|---|---|---|
| `intentId` | string | yes | Caller-supplied unique ID (UUID recommended) |
| `chainId` | integer | yes | Numeric chain ID (e.g. 56, 137, 728126428) |
| `tokenAddress` | string | yes | Token contract address. EVM/Tron: lowercase 0x hex. TON: exact base64url or raw format |
| `destination` | string | yes | Receiving wallet address. EVM/Tron: 0x hex. TON: base64url |
| `amount` | string | yes | Amount in smallest unit (wei / token decimals) as a base-10 integer string |
| `callbackUrl` | string | yes | URL the scanner POSTs to on confirmation |
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` verification |
| `confirmations` | integer | no | Override chain default confirmation count (0 = use chain default) |
**Example request:**
```json
{
"intentId": "a1b2c3d4-...",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xAbCd1234...",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
"callbackSecret": "abc123...",
"confirmations": 12
}
```
**Response `200 OK`:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"checkoutBlock": {
"destination": "0xabcd1234...",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"chainId": 56,
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"feeAmount": "0",
"feeAddress": "0x000000000000000000000000000000000000dEaD",
"amountWei": "10000000000000000000"
}
}
```
**Idempotency**: If `intentId` already exists the existing intent's checkout block is returned (no error).
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"intentId is required"}` | Missing field |
| 400 | `{"error":"amount must be a positive integer string (base-10 wei)"}` | Non-numeric or zero amount |
| 400 | `{"error":"unsupported chainId: 999"}` | Chain not in supported-chains.json |
| 500 | `{"error":"internal error"}` | DB write failure |
---
## GET /intents/{intentId}
Fetch the current state of a payment intent.
**Response `200 OK`:** Full `Intent` object (see Data Models below).
`callbackSecret` is excluded from the response regardless of auth state.
**Error cases:**
| Status | Body | Cause |
|---|---|---|
| 404 | `{"error":"intent not found"}` | Unknown intentId |
---
## GET /scanner/status
Returns scan progress for all verified chains.
**Response `200 OK`:**
```json
{
"chains": [
{
"chainId": 56,
"name": "BSC",
"chainType": "evm",
"lastScannedBlock": 39000000,
"chainHead": 39000015,
"lag": 15,
"pendingIntents": 3
},
{
"chainId": 728126428,
"name": "TRX",
"chainType": "tron",
"lastScannedBlock": 1748500000000,
"chainHead": 1748500015000,
"lag": 15000,
"pendingIntents": 1
},
{
"chainId": 1100,
"name": "TON",
"chainType": "ton",
"lastScannedBlock": 1748500000,
"chainHead": 1748500015,
"lag": 15,
"pendingIntents": 0
}
]
}
```
**Note on lag units**: For EVM and Tron chains, `lag` is in blocks (or ms-timestamp difference). For TON, `lag` is in seconds (Unix timestamps).
---
## POST /admin/webhooks/retry
Immediately trigger a re-delivery attempt for all `webhook_failed` intents. Normally the scanner retries automatically every `WEBHOOK_RETRY_HOURS`; this endpoint forces an immediate pass.
**Response `200 OK`:**
```json
{ "queued": 2 }
```
Each retry is dispatched in a separate goroutine. Success resets the intent status to `confirmed` and records `webhook_delivered_at`.
---
## GET /health
Health check. No authentication required.
**Response `200 OK`:**
```json
{ "status": "ok", "time": "2026-05-30T12:00:00Z" }
```
Used by Docker `HEALTHCHECK` and upstream load balancers / Gatus monitoring.
---
## Webhook delivery (outbound)
When an intent is confirmed the scanner POSTs to `callbackUrl`:
**Headers:**
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
| `X-AMN-Delivery-ID` | intentId |
| `X-AMN-Retry` | `true` (only on manual retry via /admin/webhooks/retry) |
**Body:**
```json
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"txHash": "0xdeadbeef...",
"blockNumber": 39000010,
"amount": "10000000000000000000",
"token": "0x55d398326f99059ff775485246999027b3197955",
"chainId": 56,
"status": "confirmed"
}
```
**Retry schedule** (on non-2xx or network error): 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
The backend should verify `X-AMN-Signature` to reject forged callbacks:
```js
const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('hex');
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
```
---
## Data models
### Intent object
```json
{
"intentId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"destination": "0x...",
"amount": "10000000000000000000",
"paymentReference": "0x1a2b3c4d",
"topicRef": "0xdeadbeef...",
"status": "pending | confirming | confirmed | expired | webhook_failed",
"confirmationsRequired": 12,
"txHash": null,
"logIndex": null,
"blockNumber": null,
"confirmations": 0,
"salt": "hex64chars",
"webhookDeliveredAt": null,
"createdAt": "2026-05-30T10:00:00Z",
"updatedAt": "2026-05-30T10:00:00Z"
}
```
Note: `callbackUrl` and `callbackSecret` are present in the DB but `callbackSecret` is always omitted from API responses.

View File

@@ -5,6 +5,8 @@ tags: [api, socket, realtime, reference]
# Socket Events # Socket Events
> **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))
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts): The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts):
```ts ```ts
@@ -58,11 +60,10 @@ Grouped by the service that emits them.
| Event | Room | Payload | Source | | Event | Room | Payload | Source |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `new-purchase-request` | `sellers` | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` | | `new-purchase-request` | `sellers` (shared global room) | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` |
| `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` | | `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` |
| `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` | | `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` |
| `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` | | `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` |
| `request-cancelled` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, reason }` | `PurchaseRequestService` |
| `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` | | `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` |
| `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` | | `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` |
| `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` | | `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` |
@@ -72,6 +73,8 @@ Grouped by the service that emits them.
| `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` | | `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` |
| `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` | | `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` |
> **Note:** There is **no** `request-cancelled` event. When a purchase request is cancelled, `PurchaseRequestService` emits `purchase-request-update` with `eventType: 'status-changed'` to the `request-<requestId>` room. Any code listening for `request-cancelled` will never fire.
### Payment ### Payment
| Event | Room | Payload | Source | | Event | Room | Payload | Source |
@@ -98,6 +101,8 @@ Grouped by the service that emits them.
Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers. Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers.
> **Note:** There is **no** `notification-read` event. Cross-tab unread badge synchronisation is handled by `unread-count-update` (see Notification table below), not by a dedicated read event.
### Notification ### Notification
| Event | Room | Payload | | Event | Room | Payload |
@@ -107,15 +112,21 @@ Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`c
Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts). Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts).
`unread-count-update` is the canonical cross-tab sync mechanism for the notification badge. It is emitted whenever the unread count changes (new notification or mark-as-read).
### Points ### Points
| Event | Room | Payload | | Event | Room | Payload | Source |
| --- | --- | --- | | --- | --- | --- | --- |
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | | `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | `PointsService` |
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | | `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | `PointsService` |
| `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` | | `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` | `authController` (auth domain, **not** `PointsService`) |
Sources: [`PointsService.ts`](../../backend/src/services/points/PointsService.ts), [`authController.ts`](../../backend/src/services/auth/authController.ts). > **Note on `referral-signup`** — This event is emitted by `authController` when a referred user completes registration, not by `PointsService`. It belongs to the authentication domain. `PointsService` emits only `level-up` and `referral-reward`.
### Disputes
> ⚠️ **TODO stubs** — `DisputeService` does not currently emit any Socket.IO events. All socket event handlers in `DisputeService` are placeholder stubs. No real-time dispute notifications fire regardless of dispute status changes.
## Online status ## Online status

View File

@@ -3,9 +3,11 @@ title: Trezor API
tags: [api, payments, trezor, safekeeping] tags: [api, payments, trezor, safekeeping]
--- ---
> **Last updated:** 2026-05-30 — break-glass mode added (commit `b21df25`)
# Trezor API # Trezor API
The Trezor API is mounted at `/api/trezor`. It is optional support for hardware-backed safekeeping and does not replace SHKeeper or Request Network. The Trezor API is mounted at `/api/trezor`. It is optional support for hardware-backed safekeeping and does not replace Request Network checkout, the funds ledger, or the broader Safe/multisig custody roadmap.
Enforcement is controlled by: Enforcement is controlled by:
@@ -15,6 +17,12 @@ TREZOR_SAFEKEEPING_REQUIRED=false
Only the literal value `true` makes Trezor proof mandatory during release/refund confirmation. When unset or `false`, release/refund flows continue without Trezor proof. Only the literal value `true` makes Trezor proof mandatory during release/refund confirmation. When unset or `false`, release/refund flows continue without Trezor proof.
## Break-glass mode
When `TREZOR_SAFEKEEPING_REQUIRED=true` and the Trezor is unavailable (lost, dead battery, etc.), an admin can activate break-glass mode to bypass Trezor for up to 1 hour. Break-glass state is in-memory only and resets on server restart.
See [[Admin API]] — _Break-glass (Trezor bypass)_ section for the three management endpoints (`GET`, `POST`, `DELETE /api/admin/settings/break-glass`). Activating break-glass fires an immediate Telegram alert via `tgNotify`.
## GET /api/trezor/registration-message ## GET /api/trezor/registration-message
Builds the exact message the user must sign to register a Trezor xpub. Builds the exact message the user must sign to register a Trezor xpub.
@@ -80,10 +88,26 @@ Response:
## GET /api/trezor/account ## GET /api/trezor/account
Returns the caller's active Trezor registration summary. Returns the caller's active Trezor registration summary. If no Trezor has been registered for the authenticated user, returns `{ registered: false }` without an error.
Auth: bearer JWT Auth: bearer JWT
Response when registered:
```json
{
"success": true,
"data": {
"registered": true,
"xpubFingerprint": "0x...",
"registrationAddress": "0x...",
"basePath": "m/44'/60'/0'",
"deviceLabel": "Office Trezor",
"nextAddressIndex": 3
}
}
```
Response when absent: Response when absent:
```json ```json
@@ -148,7 +172,7 @@ Response:
## POST /api/trezor/verify-operation ## POST /api/trezor/verify-operation
Verifies a signed operation intent against the admin's registered Trezor safekeeping address. Admin-only standalone signature verification endpoint. Verifies a signed operation intent against the admin's registered Trezor safekeeping address without performing any release or refund. Use this to validate a Trezor proof before submitting it to the release/refund flow.
Auth: bearer JWT, admin Auth: bearer JWT, admin

View File

@@ -5,6 +5,8 @@ tags: [api, user, reference]
# User API # User API
> **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))
Two routers are mounted for users: Two routers are mounted for users:
- `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`. - `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`.
@@ -75,29 +77,78 @@ Avatar upload is handled by the [[File API]]:
### GET /api/user/wallet-address ### GET /api/user/wallet-address
**Description:** Returns the caller's stored EVM wallet address (or `null`). **Description:** Returns the caller's stored wallet address plus its chain type and provider (each `null` if unset).
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." | null } }` **Response 200:**
```json
{
"success": true,
"data": {
"walletAddress": "0x..." , // or null
"walletType": "evm" , // "evm" | "ton" | null (the chain family)
"walletProvider": "evm" // e.g. "evm" | "telegram-wallet" | null
}
}
```
(Earlier docs listed only `walletAddress`; the endpoint also returns `walletType` and `walletProvider`.)
### PATCH /api/user/wallet-address ### PATCH /api/user/wallet-address
**Description:** Verifies an EIP-191 signed message and stores `profile.walletAddress`. The server uses `ethers.verifyMessage(message, signature)` and rejects if the recovered address does not match. **Description:** Stores a verified wallet address. Supports **both EVM and TON**:
- **EVM** (`walletType` omitted or not `'ton'`): the address must pass `ethers.isAddress`, and the body must include `signature` + `message`. The server runs `ethers.verifyMessage(message, signature)` (EIP-191) and rejects if the recovered address does not match.
- **TON** (`walletType: 'ton'`): the address is validated against a TON address regex. An optional `tonProof` payload is verified via `verifyTonProof`; if valid, `profile.walletProofVerified` is set to `true` and `profile.walletProofTimestamp` is stamped.
On success the server writes `profile.walletAddress`, `profile.walletType` (`'evm'` or `'ton'`), `profile.walletProvider`, and `profile.walletProofVerified`.
**Auth required:** Bearer JWT **Auth required:** Bearer JWT
**Request body:** **Request body:**
```ts ```ts
{ {
walletAddress: string; // 0x-prefixed 40-hex walletAddress: string; // EVM 0x-address, or TON address
signature: string; // signed `message` walletType?: "evm" | "ton"; // defaults to "evm"
message: string; // human-readable challenge text walletProvider?: string; // defaults to "telegram-wallet" for ton, "evm" otherwise
// EVM only:
signature?: string; // required for EVM — signed `message`
message?: string; // required for EVM — human-readable challenge text
// TON only:
tonProof?: TonProofPayload; // optional; when valid sets walletProofVerified=true
} }
``` ```
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." } }` **Response 200:** `{ "success": true, "data": { "user": { /* sanitized user */ }, "walletProofVerified": boolean } }`
**Errors:** **Errors:**
- `400` missing fields, malformed address, signature mismatch - `400` missing/invalid fields, malformed address, EVM signature mismatch, invalid TON proof
- `404` user not found - `404` user not found
The legacy alias `PATCH /api/users/wallet-address` performs the same logic. The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
### POST /api/user/wallet-address/ton-proof/challenge
**Description:** Generates a TON proof nonce/challenge for TON wallet address verification. The returned challenge is then signed by the client and submitted for verification.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true, "data": { /* challenge/nonce payload */ } }`
**Source:** Backend implements this endpoint for TON proof nonce generation.
## Email verification
### POST /api/user/profile/email/verify
**Description:** Re-verifies the caller's email address after an email change using a 6-digit code sent to the new address.
**Auth required:** Bearer JWT
**Request body:**
```ts
{
code: string; // 6-digit verification code
}
```
**Response 200:** `{ "success": true, "data": { /* updated user */ } }`
**Source:** `axios.ts` defines this endpoint; used after email change flow.
### POST /api/user/profile/email/resend-verification
**Description:** Resends the 6-digit email verification code to the caller's (new) email address.
**Auth required:** Bearer JWT
**Response 200:** `{ "success": true }`
**Source:** `axios.ts` defines this endpoint; used in email change / re-verification flow.
## Contacts and search ## Contacts and search
### GET /api/users/contacts ### GET /api/users/contacts
@@ -122,7 +173,17 @@ The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
## Admin: user management ## Admin: user management
These are duplicated across the two routers. The newer controller variants live under `/api/user/admin/*`; the legacy bodies live under `/api/users/admin/*`. All require `req.user.role === 'admin'` (the legacy routes check inline; the controller routes only check `authenticateToken` and the controller enforces the role). > **Note on the two admin route groups (prefix inconsistency).** There are TWO parallel admin route groups:
> - **Singular `/api/user/admin/*`** — the NEW controller (`userControllerRoutes.ts` → `userController`). This is where create / delete / status / role / list / dependencies are actually *registered* on the new controller.
> - **Plural `/api/users/admin/*`** — the LEGACY router (`userRoutes.ts`), which also mounts admin sub-routes (status, role, password, single-user fetch/update, resend-verification, stats).
>
> ⚠️ **The frontend consistently calls the PLURAL `/api/users/admin/*`** (see `frontend/src/lib/axios.ts`, all paths under `endpoints.users.admin.*`). So the singular create/delete/status/role/list paths below are *documented*, but in practice the frontend hits the legacy plural group. Both are listed; treat the plural group as the frontend-effective reality.
>
> ⚠️ **Note on HTTP verbs (KNOWN BUG):** The frontend `updateUserStatus` and `updateUserRole` calls (`frontend/src/actions/user.ts`) use **`PUT`** (`PUT /api/users/admin/:id/status`, `PUT /api/users/admin/:id/role`). The backend registers these as **`PATCH`** only (both the legacy and new routers). The verbs do not match — treat `PATCH` as the authoritative backend verb; the `PUT` calls will not route.
>
> ⚠️ **Note on status values (KNOWN BUG):** The frontend `updateUserStatus` TypeScript type is `'active' | 'inactive' | 'pending'`. The backend `User.status` enum is `'active' | 'suspended' | 'deleted'`. So:
> - `'inactive'` and `'pending'` are **rejected/ignored** by the backend (the new controller only applies `status` when it is one of `active`/`suspended`/`deleted`).
> - `'suspended'` — the actually-usable suspend value — is **missing from the frontend type**, so the admin UI cannot send it.
### POST /api/user/admin/create ### POST /api/user/admin/create
@@ -144,31 +205,49 @@ These are duplicated across the two routers. The newer controller variants live
**Response 201:** `{ success, data: { user } }` **Response 201:** `{ success, data: { user } }`
**Errors:** `400` missing fields, `403` non-admin, `409` email exists. **Errors:** `400` missing fields, `403` non-admin, `409` email exists.
### DELETE /api/user/admin/:userId ### DELETE /api/user/admin/:userId (new controller — SOFT delete)
**Description:** Hard-delete a user. Prevents self-deletion and deleting other admins. **Description:** **Soft-delete** — sets `status = 'deleted'` via `findByIdAndUpdate` (the user document is retained). Only blocks **self-deletion** (`userId === req.user.id`).
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Response 200:** `{ success, data: { deletedUserId } }` **Response 200:** `{ success, data: { deletedUserId } }`
**Errors:** `400` self-delete, `403` admin-on-admin, `404` not found. **Errors:** `400` self-delete, `404` not found.
### PATCH /api/user/admin/:userId/status > ⚠️ **Behavior diverges from the legacy DELETE — and a privilege concern.** The new controllers soft-delete does **NOT** block an admin from deleting *other* admins (it only blocks deleting yourself). By contrast, the legacy `DELETE /api/users/admin/:id` (below) is a **HARD delete** (`findByIdAndDelete`, removes the document) and **does** block admin-on-admin deletion. The two endpoints behave differently in both deletion semantics (soft vs hard) and authorization (self-only vs admin-on-admin block).
**Description:** Activate / suspend a user. ### DELETE /api/users/admin/:id (legacy router — HARD delete)
**Description:** **Hard-delete** — permanently removes the user document via `findByIdAndDelete`. Blocks deleting other admins.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ isActive: boolean; reason?: string }` **Errors:** `403` admin-on-admin, `404` not found.
**Response 200:** `{ success, data: { user: { _id, isActive, statusUpdatedAt } } }`
### PATCH /api/user/admin/:userId/status (and legacy PATCH /api/users/admin/:id/status)
**Description:** Update a user's status and/or email-verified flag. Registered on the new controller as `/api/user/admin/:userId/status`; the legacy plural `/api/users/admin/:id/status` is what the frontend actually calls.
**Auth required:** Bearer JWT (admin)
**Request body:**
```ts
{
status?: "active" | "suspended" | "deleted"; // applied only if one of these three values
isEmailVerified?: boolean; // new controller also accepts this — sets User.isEmailVerified
reason?: string;
}
```
The new controller only writes `status` when it is exactly `active`, `suspended`, or `deleted`; any other value (e.g. the frontend's `inactive`/`pending`) is silently ignored. It additionally accepts an `isEmailVerified` boolean to flip the user's email-verified flag.
**Response 200:** `{ success, data: { user } }` (sanitized user without password)
**⚠️ Frontend discrepancy (KNOWN BUG):** Frontend calls this with the `PUT` verb and sends `status: 'active' | 'inactive' | 'pending'`; the backend registers `PATCH` and only honors `active`/`suspended`/`deleted`. See the admin routing note above.
### PATCH /api/user/admin/:userId/toggle-status ### PATCH /api/user/admin/:userId/toggle-status
**Description:** Flip active/suspended without explicit body. **Description:** Flip active/suspended without explicit body.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
### PATCH /api/user/admin/:userId/role ### PATCH /api/users/admin/:userId/role
**Description:** Change a user's role. **Description:** Change a user's role.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }` **Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }`
**Errors:** `400` invalid role. **Errors:** `400` invalid role.
**Frontend discrepancy:** Frontend calls this with `PUT` verb; backend only accepts `PATCH`.
### GET /api/user/admin/list ### GET /api/user/admin/list
@@ -184,8 +263,9 @@ These are duplicated across the two routers. The newer controller variants live
### GET /api/users/admin/stats ### GET /api/users/admin/stats
**Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d). **Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d). (Undocumented previously.)
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Note:** No frontend UI actually consumes this. The endpoint path exists in `axios.ts` (`endpoints.users.admin.stats`), but the admin overview computes its figures client-side from `getPurchaseRequests()`, not from this endpoint.
### GET /api/users/admin/:userId ### GET /api/users/admin/:userId
@@ -210,10 +290,12 @@ These are duplicated across the two routers. The newer controller variants live
### POST /api/users/admin/:userId/resend-verification ### POST /api/users/admin/:userId/resend-verification
**Description:** Regenerate the 8-digit email verification code and re-send the verification email. **Description:** Regenerate the email verification code and re-send the verification email.
**Auth required:** Bearer JWT (admin) **Auth required:** Bearer JWT (admin)
**Errors:** `400` user already verified. **Errors:** `400` user already verified.
> ⚠️ **Email code length inconsistency.** The legacy `userRoutes.ts` generates an **8-digit** code (`10000000 + Math.random() * 90000000`), while the new `userController` (used by `POST /api/user/profile/email/verify` and the email-change flow) generates a **6-digit** code (`crypto.randomInt(100000, 1000000)`). Code length therefore depends on which path issued it.
## Address book ## Address book
Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]]. Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]].

View File

@@ -5,6 +5,11 @@ related_models: ["[[User]]", "[[TempVerification]]"]
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"] related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit note — last reviewed 2026-05-29
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
# Authentication Flow # Authentication Flow
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription. End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
@@ -32,7 +37,8 @@ End-to-end specification for **email + password** authentication, JWT issuance,
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error. 2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout. 3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`. 4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts. 5. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
5a. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected. 6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected.
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design. 7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`. 8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
@@ -49,7 +55,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
> [!warning] Token storage is `localStorage`, not cookies > [!warning] Token storage is `localStorage`, not cookies
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**. > Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request and, on `401/403`, automatically calls the refresh flow described below. 16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller. The interceptor only checks `status === 401` (`axios.ts:105`); 403 responses are not handled by the interceptor and propagate as errors.
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`. 17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
## Sequence diagram ## Sequence diagram
@@ -99,6 +105,7 @@ sequenceDiagram
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` | | `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` |
| `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) | | `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) |
| `GET` | `/api/auth/profile` | `authRoutes.ts:69``authController.getProfile` | | `GET` | `/api/auth/profile` | `authRoutes.ts:69``authController.getProfile` |
| `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89``authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) |
## Telegram first-class auth flow ## Telegram first-class auth flow
@@ -116,6 +123,10 @@ Telegram is now a peer auth provider alongside email/password, Google, and passk
High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session. High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session.
## Passkey auth flow
The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend.
## Database writes ## Database writes
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh. - **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
@@ -129,19 +140,25 @@ High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and
## Side effects ## Side effects
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`). - **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
- **Redis rate-limit counter**: TTL 15 min, reset on success. - **Redis rate-limit counter**: TTL 15 min, reset on success. Counter increments on every attempt regardless of outcome.
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement). - **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`). - **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
## Refresh-token flow ## Refresh-token flow
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls: The access token is short-lived. When a protected request returns `401 TOKEN_INVALID`, the axios interceptor calls:
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`. 1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token. 2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes. 3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
4. The new pair is written back to `localStorage` and the original failed request is retried. 4. The new pair is written back to `localStorage` and the original failed request is retried.
> [!note] 403 responses are not retried
> The interceptor only triggers token refresh for `status === 401` (`axios.ts:105`). A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
> [!warning] Refresh-token sequence diagram is truncated
> The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
@@ -155,3 +172,46 @@ sequenceDiagram
BE->>BE: verifyRefreshToken(refreshToken) BE->>BE: verifyRefreshToken(refreshToken)
BE->>DB: User.findById(decoded.id) BE->>DB: User.findById(decoded.id)
BE->>DB: ensure refresh token is in user.refreshTokens BE->>DB: ensure refresh token is in user.refreshTokens
Note over BE,DB: (diagram truncated — remaining steps documented in prose above)
```
## Account management
### changePassword (API-only)
`POST /api/auth/change-password` exists on the backend and the `changePassword()` action is defined in `frontend/src/auth/context/jwt/action.ts`. However:
> [!warning] No frontend UI for change-password
> There is **no dashboard page** that renders a change-password form. The feature is **API-only** at this time. Users cannot change their password through the UI; a developer or direct API client must call the endpoint manually.
### deleteAccount
> [!bug] Account deletion frontend calls wrong endpoint
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (`authRoutes.ts:86-89`), which requires a `password` field in the request body and runs `deleteAccountValidation` middleware. Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
## Known issues summary
| Issue | Severity | Details |
|---|---|---|
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; correct backend endpoint is `DELETE /api/auth/account` (requires `password` in body) |
| No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form |
| Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout |
| Axios interceptor 401-only | Clarification | Interceptor only auto-refreshes on `status === 401` (`axios.ts:105`); 403 errors propagate directly to caller |
| Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative |
## Linked flows
- [[Registration Flow]] — prerequisite; user must be verified.
- [[Password Reset Flow]] — alternative credential recovery path.
- [[Notification Flow]] — uses the issued JWT for Socket.IO room subscriptions.
- [[Chat Flow]] — same JWT used for chat room access.
## Source files
- Backend: `backend/src/services/auth/authController.ts`
- Backend: `backend/src/services/auth/authService.ts`
- Backend: `backend/src/services/auth/authValidation.ts`
- Backend: `backend/src/services/auth/authRoutes.ts`
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
- Frontend: `frontend/src/auth/context/jwt/action.ts`
- Frontend: `frontend/src/lib/axios.ts`

View File

@@ -2,10 +2,11 @@
title: Chat Flow title: Chat Flow
tags: [flow, chat, socket-io, messaging] tags: [flow, chat, socket-io, messaging]
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"] related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"] related_apis: ["POST /api/chat", "POST /api/chat/:id/messages", "GET /api/chat/:id/messages", "PATCH /api/chat/:id/messages/read"]
--- ---
# Chat Flow # Chat Flow
> **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))
Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates. Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.
@@ -18,7 +19,7 @@ Real-time messaging between buyer & seller (direct), three-way dispute mediation
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer). - **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`. - **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`. - **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`. - **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`, `message-deleted`.
## Preconditions ## Preconditions
@@ -32,8 +33,8 @@ stateDiagram-v2
[*] --> Created: ChatService.createChat\n(or auto on first contact) [*] --> Created: ChatService.createChat\n(or auto on first contact)
Created --> Active: messages flowing Created --> Active: messages flowing
Active --> Active: send / read / typing Active --> Active: send / read / typing
Active --> Archived: settings.isArchived=true Active --> Archived: PATCH /api/chat/:id/archive (toggle)
Archived --> Active: unarchive Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
Active --> [*]: chat deleted (rare) Active --> [*]: chat deleted (rare)
``` ```
@@ -41,25 +42,33 @@ stateDiagram-v2
### Creation ### Creation
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`. 1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [sellerId] }`. The endpoint requires **exactly 1 external `participantId`**; the authenticated caller is auto-appended to make 2.
> [!warning] `relatedTo` is NOT accepted on `POST /api/chat`
> Despite the schema carrying a `relatedTo` discriminator, the create endpoint ignores/does not accept a `relatedTo` payload. Purchase-request linkage is performed server-side via the dedicated `POST /api/chat/purchase-request` (see step 5), not by passing `relatedTo` to `POST /api/chat`.
2. `ChatService.createChat` (`ChatService.ts:90-192`): 2. `ChatService.createChat` (`ChatService.ts:90-192`):
- For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found. - For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
- Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`. - Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
- Appends a system welcome message (`messageType: 'system'`). - Appends a system welcome message (`messageType: 'system'`).
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response. - Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`). 3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
4. **Support chat**`ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent. 4. **Support chat**`ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
5. **Post-payment auto-chat** — when SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure a direct chat exists between buyer and winning seller. 5. **Post-payment / purchase-request auto-chat**`POST /api/chat/purchase-request` exists on the backend and creates/links a direct chat for a purchase request. When payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller. **No frontend action is wired to `POST /api/chat/purchase-request`** — this direct chat is created server-side.
### Joining the room (real-time) ### Joining the room (real-time)
6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`. 6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
7. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`). 7. **`join-user-room` and `user-online` are SEPARATE events** (do not conflate them):
- `socket.emit('join-user-room', userId)` makes the socket join the personal `user-{userId}` room (so it can receive `chat-notification`).
- `socket.emit('user-online', userId)` broadcasts a `user-status-change` (online) to other clients.
> [!warning] No offline broadcast on disconnect — stale "online" status
> On socket disconnect, **no offline `user-status-change` is emitted**. Other users keep seeing a stale "online" indicator for a peer who has actually left. Document this as a known gap.
### Sending a message ### Sending a message
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`. 8. User types and hits send. Frontend POSTs `POST /api/chat/:id/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`. Backend enforces a **5000-character maximum** on `content` at both Mongoose schema and controller validation levels.
9. `ChatService.sendMessage` (`:195-260`): 9. `ChatService.sendMessage` (`:195-260`):
- Loads chat, verifies the sender is in `participants[]` and `isActive`. - Loads chat, verifies the sender is in `participants[]` and `isActive`.
- Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`. - Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
@@ -71,20 +80,64 @@ stateDiagram-v2
### Attachments ### Attachments
11. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`). 11. **File upload endpoint:** the real endpoint is **`POST /api/chat/:id/messages/file`** (multipart/form-data). The flow previously referenced `POST /api/chat/:chatId/upload`, which **does NOT exist**.
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
> [!bug] ⚠️ KNOWN BUG — file uploads broken
> The frontend `chatService.sendFileMessage` currently POSTs to the **text** message endpoint (`POST /api/chat/:id/messages`) instead of `POST /api/chat/:id/messages/file`. As a result file uploads are broken — they hit the wrong endpoint.
12. When working correctly, the backend handles the multipart payload at `POST /api/chat/:id/messages/file`, persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`), and records the message with `messageType: 'image' | 'file'`.
> [!warning] ⚠️ Security concern — anonymous file access
> Uploaded files are stored under `uploads/chat/` and served with **anonymous access**. Sensitive attachments (KYC docs, dispute evidence) are fetchable by any user who has the URL. Consider signed URLs or per-user authorisation.
### Editing a message
13. Editing a message uses a body of `{ content }` (max 5000 chars). Edits are only allowed within a **15-minute edit window** — edits attempted after that return **400**.
> [!bug] ⚠️ KNOWN BUG — edits fail / are ignored
> The frontend `editMessage` action sends `{ text }`, but the backend expects `{ content }`. The mismatched field name means edits fail or are silently ignored.
### Deleting a message (soft-delete)
14. Message DELETE **soft-deletes**: it sets `deletedAt`, clears the message `content`, and emits **`message-deleted`** to `chat-{chatId}`. The subdocument is not physically removed.
### Read receipts ### Read receipts
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`). 15. When the user opens a chat, frontend marks messages read via **`PATCH /api/chat/:id/messages/read`** (note: **PATCH**, not POST; there is no `POST /api/chat/:chatId/read`). The body may carry `messageIds: string[]`; if `messageIds` is **empty or omitted, ALL messages are marked read**.
14. `ChatService.markMessagesAsRead` (`:438-483`): 16. `ChatService.markMessagesAsRead` (`:438-483`):
- Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry). - Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
- Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick. - Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
### Typing indicator ### Typing indicator
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`. 17. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence. 18. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence. Limited to **5 typing indicators per 10 seconds**.
### Participants (add / remove / role)
19. **Add a participant** — real endpoint `POST /api/chat/:id/participants` expects a body of **`{ userId }` (a single id)**.
> [!bug] ⚠️ KNOWN BUG — add participant payload mismatch
> The frontend `addParticipants` action sends `{ participants: string[] }` (an array), but the backend expects `{ userId }` (a single id). The shapes do not match.
20. **Remove / leave** — to remove a participant (or have a user leave), use `DELETE /api/chat/:id/participants/:participantId`. Removal is a **soft removal**: the participant subdocument is kept with `isActive=false` and a `leftAt` timestamp.
> [!bug] ⚠️ KNOWN BUG — leave action 404s
> `PUT /chat/:id/leave` **does NOT exist** on the backend. The frontend `leaveConversation` action targets that path and therefore **404s**. Use `DELETE /api/chat/:id/participants/:participantId` instead.
21. **List participants**
> [!bug] ⚠️ KNOWN BUG — getParticipants 404s
> `GET /chat/:id/participants` **does NOT exist** — the backend only exposes `POST` (add) and `DELETE` (remove) on that path. The frontend `getParticipants` action 404s. Participants must be read from **`GET /api/chat/:id/info`** instead.
22. **Change a participant role**
> [!bug] ⚠️ NOT IMPLEMENTED — updateParticipantRole
> `PUT /chat/:id/participants/:participantId` **does NOT exist** on the backend. The frontend `updateParticipantRole` action has no backend counterpart.
### Chat info
23. `getChatInfo``GET /api/chat/:id/info` returns chat details **plus only the first 50 messages** (page 1, limit 50) — **not** the full message history. Use the paginated `GET /api/chat/:id/messages` to load older messages.
## Sequence diagram ## Sequence diagram
@@ -100,22 +153,28 @@ sequenceDiagram
participant IO as Socket.IO participant IO as Socket.IO
A->>FE_A: Open conversation A->>FE_A: Open conversation
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo} FE_A->>BE: POST /api/chat {type:direct, participantIds:[sellerId]}
BE->>DB: find-or-create Chat BE->>DB: find-or-create Chat (caller auto-appended)
BE-->>FE_A: { chat } BE-->>FE_A: { chat }
FE_A->>IO: emit 'join-chat-room' chatId FE_A->>IO: emit 'join-chat-room' chatId
FE_A->>IO: emit 'join-user-room' userId (separate from user-online)
FE_B->>IO: emit 'join-chat-room' chatId (when B opens too) FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
A->>FE_A: type & send A->>FE_A: type & send
FE_A->>BE: POST /api/chat/{id}/messages {content} FE_A->>BE: POST /api/chat/{id}/messages {content} (max 5000 chars)
BE->>DB: chat.addMessage and update metadata.lastActivity to now BE->>DB: chat.addMessage and update metadata.lastActivity to now
BE->>IO: emit chat-{id} 'new-message' BE->>IO: emit chat-{id} 'new-message'
IO-->>FE_A: 'new-message' (echo) IO-->>FE_A: 'new-message' (echo)
IO-->>FE_B: 'new-message' (live) IO-->>FE_B: 'new-message' (live)
BE->>IO: emit user-{B} 'chat-notification' (badge) BE->>IO: emit user-{B} 'chat-notification' (badge)
A->>FE_A: attach file
FE_A->>BE: POST /api/chat/{id}/messages/file (multipart/form-data)
BE->>DB: chat.addMessage with fileUrl/fileName/fileSize
BE->>IO: emit chat-{id} 'new-message'
B->>FE_B: opens chat B->>FE_B: opens chat
FE_B->>BE: POST /api/chat/{id}/read FE_B->>BE: PATCH /api/chat/{id}/messages/read (empty messageIds = all)
BE->>DB: chat.markAsRead(B) BE->>DB: chat.markAsRead(B)
BE->>IO: emit chat-{id} 'messages-read' BE->>IO: emit chat-{id} 'messages-read'
IO-->>FE_A: 'messages-read' (double-tick) IO-->>FE_A: 'messages-read' (double-tick)
@@ -128,25 +187,49 @@ sequenceDiagram
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose |
|---|---|---| |---|---|---|
| `POST` | `/api/chat` | Find-or-create chat | | `POST` | `/api/chat` | Find-or-create chat (exactly 1 external `participantId`; caller auto-appended; `relatedTo` NOT accepted) |
| `GET` | `/api/chat` | List user's chats | | `GET` | `/api/chat` | List user's chats |
| `GET` | `/api/chat/:chatId/messages` | Paginated message history | | `GET` | `/api/chat/:id/info` | Chat details + first 50 messages (page 1, limit 50) + participants |
| `POST` | `/api/chat/:chatId/messages` | Send message | | `GET` | `/api/chat/:id/messages` | Paginated message history |
| `POST` | `/api/chat/:chatId/upload` | Upload attachment | | `POST` | `/api/chat/:id/messages` | Send text message |
| `POST` | `/api/chat/:chatId/read` | Mark read | | `POST` | `/api/chat/:id/messages/file` | Send file attachment (multipart/form-data) |
| `PATCH` | `/api/chat/:id/messages/read` | Mark read (empty/omitted `messageIds` marks ALL read) |
| `PUT` | `/api/chat/:id/messages/:messageId` | Edit message — body `{ content }`, 15-min edit window |
| `DELETE` | `/api/chat/:id/messages/:messageId` | Soft-delete a message (`deletedAt`, content cleared, emits `message-deleted`) |
| `POST` | `/api/chat/:id/participants` | Add a participant — body `{ userId }` (single) |
| `DELETE` | `/api/chat/:id/participants/:participantId` | Remove / leave (soft: `isActive=false`, `leftAt`) |
| `POST` | `/api/chat/support` | Create/get support chat | | `POST` | `/api/chat/support` | Create/get support chat |
| `POST` | `/api/chat/purchase-request` | Create/link direct chat for a purchase request (no frontend action wired) |
| `PATCH` | `/api/chat/:id/archive` | Toggle archived state (archive **and** unarchive via same endpoint) |
> [!bug] Frontend actions that target non-existent or mismatched backend endpoints
> - `leaveConversation` → `PUT /chat/:id/leave` — **does NOT exist** (404). Use `DELETE /api/chat/:id/participants/:participantId`.
> - `getParticipants` → `GET /chat/:id/participants` — **does NOT exist** (404). Use `GET /api/chat/:id/info`.
> - `updateParticipantRole` → `PUT /chat/:id/participants/:participantId` — **NOT IMPLEMENTED** on backend.
> - `editMessage` → sends `{ text }` but backend expects `{ content }` — edits fail/ignored.
> - `addParticipants` → sends `{ participants: string[] }` but backend expects `{ userId }` (single).
> - `sendFileMessage` → POSTs to the text endpoint instead of `POST /api/chat/:id/messages/file` — file uploads broken.
## Rate limits & constraints
- **Messages:** 20 messages / minute per user per chat.
- **Typing indicators:** 5 / 10 seconds.
- **Message dedup:** 5-minute window (duplicate sends within the window are de-duplicated).
- **Edit window:** 15 minutes — edits after that return **400**.
- **Message length:** 5000-character maximum (schema + controller).
## Database writes ## Database writes
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave. - **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled (archive/unarchive); message soft-delete sets `deletedAt` + clears `content`; participant removal sets `participants.$.isActive=false` + `participants.$.leftAt`.
## Socket events emitted ## Socket events emitted
- **`new-message`** → `chat-{chatId}` (every message). - **`new-message`** → `chat-{chatId}` (every message).
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge). - **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
- **`messages-read`** → `chat-{chatId}` after read mark. - **`messages-read`** → `chat-{chatId}` after read mark.
- **`message-deleted`** → `chat-{chatId}` after a message soft-delete.
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`). - **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
- **`user-status-change`** → broadcast when `user-online` is emitted. - **`user-status-change`** → broadcast when `user-online` is emitted (online only; **no offline broadcast on disconnect**).
- **`new-message`** (system) for system welcome lines on chat creation. - **`new-message`** (system) for system welcome lines on chat creation.
## Side effects ## Side effects
@@ -161,11 +244,13 @@ sequenceDiagram
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`). - **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
- **Chat not found** → `404` on `getChatMessages`. - **Chat not found** → `404` on `getChatMessages`.
- **Direct duplicate** → idempotent — `createChat` returns existing chat. - **Direct duplicate** → idempotent — `createChat` returns existing chat.
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed. - **Content too long** — backend rejects messages exceeding 5000 characters at both Mongoose schema and controller validation levels.
- **Files served from `/uploads`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch. - **Edit after 15 minutes** → `400`.
- **Files served from `uploads/chat/`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
- **Stale online status** — no offline broadcast on disconnect; peers may show "online" for a user who has left.
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection. - **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless. - **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on a 2s idle. - **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on idle (rate-limited to 5/10s server-side regardless).
> [!warning] Notification message uses placeholder sender name > [!warning] Notification message uses placeholder sender name
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX. > `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.

View File

@@ -2,17 +2,19 @@
title: Delivery Confirmation Flow title: Delivery Confirmation Flow
tags: [flow, delivery, escrow-release, code] tags: [flow, delivery, escrow-release, code]
related_models: ["[[PurchaseRequest]]", "[[Payment]]"] related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POST /api/marketplace/purchase-requests/:id/verify-delivery"] related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code/generate", "POST /api/marketplace/purchase-requests/:id/delivery-code/verify"]
--- ---
# Delivery Confirmation Flow # Delivery Confirmation Flow
After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]). > **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))
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **generates and reads out the delivery code**, the seller **verifies the code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
## Actors ## Actors
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off. - **Buyer** — after the order reaches `delivery` status, explicitly generates the delivery code and reads it out to the seller at hand-off.
- **Buyer** — confirms by entering the code in the dashboard. - **Seller** — types the code into their dashboard to confirm delivery.
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`). - **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields. - **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
- **Socket.IO** — `delivery-code-generated`, `delivery-update`. - **Socket.IO** — `delivery-code-generated`, `delivery-update`.
@@ -24,21 +26,22 @@ After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay
## Step-by-step narrative ## Step-by-step narrative
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`. 1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". The frontend action `updateDelivery` calls `PUT /api/marketplace/purchase-requests/:id/delivery`. The controller's `updateDeliveryInfo` sets `shippedAt` and advances status to `delivery`. No code is generated at this point.
2. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It: 2. **Buyer generates the delivery code** — once status is `delivery`, the buyer explicitly triggers `POST /api/marketplace/purchase-requests/:id/delivery-code/generate` (buyerId is enforced server-side). `DeliveryService.generateDeliveryCode(requestId)`:
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`). - Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`. - Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`. - Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
- Sends a notification to the buyer with the code (in-app, and via email if configured). - The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
3. **Buyer entry** — buyer meets the courier / picks up the item, enters the code in `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` (also surfaced on the buyer side via `step-5-receive-goods.tsx`). 3. **Buyer reads code to seller** — at hand-off the buyer reads the 6-digit code out loud (or shows it) to the seller.
4. **Verification**`POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`: 4. **Seller enters code** — seller types the code into `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`.
5. **Verification**`POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side). Handled by `DeliveryService.verifyDeliveryCode` (lines 180-212):
- Matches `code` against `deliveryInfo.deliveryCode`. - Matches `code` against `deliveryInfo.deliveryCode`.
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`. - Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`. - On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
- Emits `purchase-request-update` `status-changed`. - Emits `purchase-request-update` `status-changed`.
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`). - Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
5. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]]. 6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). This endpoint emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party. **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
6. **Manual fast-track** — the buyer can also tap "Confirm I received it" to skip the code (used when the code path fails — e.g. lost in transit) which patches `status` to `delivered`. This relies on admin trust. 7. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
## Sequence diagram ## Sequence diagram
@@ -53,22 +56,25 @@ sequenceDiagram
participant IO as Socket.IO participant IO as Socket.IO
S->>FE: Click "Mark as shipped" S->>FE: Click "Mark as shipped"
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"} FE->>BE: PUT /api/marketplace/purchase-requests/{id}/delivery
BE->>DB: PurchaseRequest.status="delivery" BE->>DB: PurchaseRequest.shippedAt=now, status="delivery"
BE->>BE: DeliveryService.generateDeliveryCode Note over BE,DB: No code generated here
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
BE->>IO: emit request-{id} 'delivery-code-generated'
BE->>B: notification w/ code (in-app/email)
S->>B: At hand-off, share the 6-digit code (verbally) B->>FE: View delivery code in step-5-receive-goods
B->>FE: Enter code in dashboard FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code} BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
BE->>IO: emit request-{id} 'delivery-code-generated' {code, expiresAt}
FE->>B: Display 6-digit code
B->>S: At hand-off, read the 6-digit code aloud
S->>FE: Enter code in delivery-code-verification
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/verify {code}
BE->>DB: match code, expires>now, !used BE->>DB: match code, expires>now, !used
BE->>DB: set deliveryCodeUsed = true BE->>DB: set deliveryCodeUsed = true
BE->>DB: set status = "delivered" BE->>DB: set status = "delivered"
BE->>IO: emit request-{id} 'purchase-request-update' status-changed BE->>IO: emit request-{id} 'purchase-request-update' status-changed
BE->>B: notifyDeliveryConfirmed BE->>B: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
BE->>S: notifyDeliveryConfirmed BE->>S: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
Note over BE: Auto-release timer (planned) → seller_paid → payout Note over BE: Auto-release timer (planned) → seller_paid → payout
``` ```
@@ -76,44 +82,61 @@ sequenceDiagram
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose |
|---|---|---| |---|---|---|
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped | | `PUT` | `/api/marketplace/purchase-requests/:id/delivery` | Seller marks shipped (sets shippedAt, advances to `delivery`) |
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) | | `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code | | `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) | | `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check, no delivery notifications |
### Phantom frontend actions (routes do NOT exist on backend)
These Redux/API actions exist in the frontend but call endpoints that return 404:
| Frontend action | Called path | Behaviour |
|---|---|---|
| `regenerateDeliveryCode` | `/delivery-code/regenerate` | 404s; frontend falls back to `/delivery-code/generate` |
| `getDeliveryAttempts` | `/delivery-code/attempts` | 404s — feature not implemented |
| `getDeliveryStats` | `/delivery/stats` | 404s — feature not implemented |
## Two paths to `delivered` status
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`. Both buyer and seller receive delivery-confirmed notifications (sent by `DeliveryService.verifyDeliveryCode`).
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint, and no delivery-specific notifications are sent to either party.
## Database writes ## Database writes
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`. - **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
- **`purchaserequests.shippedAt`** — set when seller calls `PUT .../delivery`.
- **`purchaserequests.status`** — `delivery``delivered` → (eventually `seller_paid``completed`). - **`purchaserequests.status`** — `delivery``delivered` → (eventually `seller_paid``completed`).
- **`notifications`** — generated for both parties. - **`notifications`** — generated for both parties (code path only).
## Socket events emitted ## Socket events emitted
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt). - **`delivery-code-generated`** → `request-{id}` room (payload: `{ requestId, code, expiresAt, timestamp }`). **⚠️ Security note:** the full 6-digit code is included in the payload and broadcast to all subscribers in the room, including the seller. The buyer dashboard displays the code; the seller receives it via socket as well.
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`). - **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
- **`purchase-request-update`** `status-changed` on `delivery → delivered`. - **`purchase-request-update`** `status-changed` on `delivery → delivered`.
- **`new-notification`** → `user-{buyerId}` with the code.
## Side effects ## Side effects
- Code is **emitted via socket and in-app notification**. If a malicious actor has access to the buyer's notifications, they could intercept and confirm delivery prematurely. Treat the code as confidential at the UI layer. - The code is displayed to the **buyer** in their dashboard. The buyer verbally shares it with the seller at hand-off. Note that the `delivery-code-generated` socket event also broadcasts the raw code to the entire request room (including the seller — see socket events section above).
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future). - Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
## Error / edge cases ## Error / edge cases
- **Wrong code** → `400 Invalid delivery code`. - **Wrong code** → `400 Invalid delivery code`.
- **Expired code** (>7 days) → `400 Code expired`. Admin can regenerate via the manual endpoint. - **Expired code** (>7 days) → `400 Code expired`. Buyer can generate a new code via `POST .../delivery-code/generate` (the `regenerateDeliveryCode` frontend action also falls through to this endpoint).
- **Already used code** → `400 Code already used`. - **Already used code** → `400 Code already used`.
- **Buyer never confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention. - **Buyer never generates / confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless. - **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
- **Lost code** → `POST /:id/delivery-code` regenerates a new 6-digit value, invalidates the old one, and re-notifies. Restrict to admin/seller to avoid abuse. - **Lost / expired code** → buyer re-triggers `POST .../delivery-code/generate` to get a fresh code, invalidating the old one.
> [!tip] Use the code as proof-of-handover > [!tip] The buyer holds the code, not the seller
> The seller should ask the courier or the buyer at the door for the code before leaving the item. If the buyer disputes "never received", an unused code is strong circumstantial evidence; a used code = buyer confirmed. > The seller should ask the buyer for the code at hand-off. If the buyer disputes "never received", an unused code is strong circumstantial evidence that delivery has not been confirmed; a used code = seller confirmed receipt.
## Linked flows ## Linked flows
- [[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]] — funding precondition. - [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — funding precondition.
- [[Escrow Flow]] — state transitions triggered by confirmation. - [[Escrow Flow]] — state transitions triggered by confirmation.
- [[Payout Flow]] — fires after confirmation (manual today). - [[Payout Flow]] — fires after confirmation (manual today).
- [[Dispute Flow]] — escape hatch. - [[Dispute Flow]] — escape hatch.
@@ -121,9 +144,8 @@ sequenceDiagram
## Source files ## Source files
- Backend: `backend/src/services/delivery/DeliveryService.ts` - Backend: `backend/src/services/delivery/DeliveryService.ts` (generateDeliveryCode, verifyDeliveryCode lines 180-212)
- Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints) - Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints)
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:631-641` (confirmation notifications)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx` - Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` - Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`

View File

@@ -3,11 +3,20 @@ title: Dispute Flow
tags: [flow, dispute, mediator, evidence, chat, state-machine] tags: [flow, dispute, mediator, evidence, chat, state-machine]
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"] related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"] related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# Dispute Flow # Dispute Flow
When something goes wrong (item not delivered, wrong item, fraud), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim. When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.
> [!success] Security fixes applied (2026-05-30)
> The three privilege-escalation bugs documented in the original Security Gaps section were fixed in commit `1d881c5` (ISSUE-003, ISSUE-004) and `fce8a19` (resolver role). Role guards are now enforced on assign/status/resolve; route shadowing is eliminated by remounting the release-hold router at `/api/disputes/pr`. See [Security Gaps](#security-gaps) for the historical record and current state.
> [!warning] Real-time events not implemented
> Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage.
## Actors ## Actors
@@ -15,11 +24,10 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator). - **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
- **Admin / Mediator** — assigned to investigate. - **Admin / Mediator** — assigned to investigate.
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard. - **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts` *(planned)*), `DisputeController` (`backend/src/controllers/disputeController.ts` *(planned)*), routes at `backend/src/routes/disputeRoutes.ts` *(planned)*. - **Admin / Mediator** — assigned to investigate (role `admin` or `resolver`).
> [!warning] Not implemented - **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted at `/api/disputes/pr` since commit `1d881c5`).
> None of these files exist as of 2026-05-24. The dispute module is planned but not yet built.
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`. - **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned). - **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
## Preconditions ## Preconditions
@@ -29,63 +37,149 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
## Dispute state machine (`Dispute.status`) ## Dispute state machine (`Dispute.status`)
Valid status values (from `Dispute.ts`): `pending | in_progress | waiting_response | resolved | rejected | closed`.
> [!caution] `under_review` does NOT exist. The correct progressed status is `in_progress`.
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d [*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
pending --> in_progress: admin assigned\nassignAdmin() pending --> in_progress: admin assigned\nassignAdmin()
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject} pending --> waiting_response: status update
in_progress --> waiting_response: status update
waiting_response --> in_progress: status update
in_progress --> resolved: admin resolves\nresolveDispute()
in_progress --> rejected: admin rejects
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam) in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
pending --> closed: same pending --> closed: same
resolved --> [*] resolved --> [*]
rejected --> [*]
closed --> [*] closed --> [*]
``` ```
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(intended design)*): `refund`, `partial`, `release`, `reject`. ## Resolution schema (`Dispute.resolution`)
```ts
resolution?: {
action: 'refund' | 'replacement' | 'compensation' | 'warning_seller' | 'ban_seller' | 'no_action';
amount?: number;
currency?: string; // 'USD' | 'EUR' | 'IRR' | 'USDT'
notes?: string;
resolvedBy: ObjectId;
resolvedAt: Date;
}
```
> [!caution] Incorrect in previous docs: `decision: buyer|seller|split` and `refundAmount` do NOT exist in the model. The field is `action` with the six values listed above.
## Dispute categories (`Dispute.category`)
Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`
> [!caution] `fraud` is NOT a valid category. Use `seller_behavior` or `other` for fraud-type reports.
---
## Security Gaps (Historical — All Closed as of 2026-05-30)
The following bugs were identified in the 2026-05-29 audit and fixed in commits `1d881c5` and `fce8a19`. The descriptions below are preserved for historical reference and audit trail.
### 1. `PATCH /api/disputes/:id/status` — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can change dispute status.
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can resolve disputes.
**Additional fix (ISSUE-004, commit `1d881c5`):** `DisputeService.resolveDispute` now calls `releaseHoldResolve()` on the linked `purchaseRequestId`, clearing the escrow hold automatically so the payment release is unblocked after resolution.
### 3. `POST /api/disputes/:id/assign` — no role guard ✅ FIXED
**Fix:** `authorizeRoles('admin', 'resolver')` added in commit `fce8a19`. Only admins and resolvers can assign mediators.
---
## Route Shadowing (Historical — Resolved as of 2026-05-30)
Previously both routers were mounted at `/api/disputes`, causing the dashboard router to intercept release-hold requests. Fixed in commit `1d881c5` (ISSUE-003):
```ts
// app.ts — current state
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
app.use("/api/disputes/pr", disputeRoutes); // src/services/dispute/disputeRoutes.ts — new prefix
```
Release-hold endpoints now use the `/api/disputes/pr/` prefix:
- `POST /api/disputes/pr/:purchaseRequestId/raise`
- `GET /api/disputes/pr/:purchaseRequestId/status`
- `POST /api/disputes/pr/:purchaseRequestId/resolve`
---
## Step-by-step narrative ## Step-by-step narrative
### Phase 1 — Opening ### Phase 1 — Opening
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`). 1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`. 2. They select a `category` (`product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`. 3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
4. Backend `DisputeService.createDispute` (`:12-119`): 4. Backend `DisputeService.createDispute` (`:12-119`):
- Loads the purchase request with `populate('selectedOfferId')`. - Loads the purchase request with `populate('selectedOfferId')`.
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId``selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list. - Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId``selectedOffer.sellerId` → first of `preferredSellerIds`. Once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. - Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. The pre-save hook appends an automatic `dispute_created` timeline entry.
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`. - Creates a **`Chat` of type `group`** with the buyer (and seller, if resolved) as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
- Persists `dispute.chatId = chat._id`. - Persists `dispute.chatId = chat._id`.
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle. 5. **Notifications: none fire.** The notification block is a TODO stub in `DisputeService.createDispute` (`:107-116`).
> [!warning] Dispute does not auto-pause escrow > [!note] Release hold behavior
> Today, opening a dispute does **not** flip `Payment.escrowState` away from `funded`. An admin could theoretically still release the escrow before resolving the dispute. Until a `disputed` flag is added to Payment, admins must check the dispute table before any release/refund action. > Opening a dispute through the release-hold router (`POST /api/disputes/:purchaseRequestId/raise`) sets hold fields on the purchase request and related payments via `releaseHoldService.raiseDispute()`. Release/refund gates can consult those fields. This is a separate code path from `DisputeService.createDispute` above.
### Phase 2 — Admin assignment ### Phase 2 — Admin assignment
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`). 6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id). 7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }`.
> [!danger] No role guard on this endpoint — any authenticated user can call it (see [Security Gaps](#security-gaps)).
8. `DisputeService.assignAdmin` (`:184-223`): 8. `DisputeService.assignAdmin` (`:184-223`):
- `dispute.adminId = adminId; dispute.status = 'in_progress'`. - `dispute.adminId = adminId; dispute.status = 'in_progress'`.
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`. - Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
- Adds the admin to the dispute `chat.participants[]` (role `admin`). - Adds the admin to the dispute `chat.participants[]` (role `admin`).
- Saves. - Saves.
- **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`)
### Phase 3 — Investigation ### Phase 3 — Investigation
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. 9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence``DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. **No socket event fires for evidence uploads.**
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. 10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. **No socket event fires.**
> [!note] `PATCH /api/disputes/:id/status` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
### Phase 4 — Resolution ### Phase 4 — Resolution
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`. 11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with:
```json
{
"action": "refund | replacement | compensation | warning_seller | ban_seller | no_action",
"amount": 150,
"currency": "USD",
"notes": "Seller failed to deliver item"
}
```
12. `DisputeService.resolveDispute` (`:262-300`): 12. `DisputeService.resolveDispute` (`:262-300`):
- `dispute.status = 'resolved'` - `dispute.status = 'resolved'`
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }` - `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
- `dispute.closedAt = now` - `dispute.closedAt = now`
- Appends `timeline` entry `dispute_resolved`. - Appends `timeline` entry `dispute_resolved`.
- Saves. - Saves.
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **payout** ([[Payout Flow]] with `kind: 'release'`) or the **refund** (`kind: 'refund'`, see [[Escrow Flow]]). The dispute service does not automatically dispatch the on-chain action. - **Calls `releaseHoldResolve(purchaseRequestId)`** — this clears the escrow hold automatically so the payment release is unblocked (ISSUE-004 fix, commit `1d881c5`).
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`). - **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
13. **Financial side-effect:** as of commit `1d881c5` the escrow hold is cleared automatically on resolution. The admin still needs to separately trigger the ledger-gated release ([[Payout Flow]] / [[Escrow Flow]]) or refund for actual fund movement.
> [!note] `POST /api/disputes/:id/resolve` now requires `admin` or `resolver` role (`authorizeRoles('admin', 'resolver')`, commit `fce8a19`). The previously open privilege-escalation gap is closed.
---
## Sequence diagram ## Sequence diagram
@@ -107,80 +201,106 @@ sequenceDiagram
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message}) BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
BE->>DB: dispute.chatId = chat._id BE->>DB: dispute.chatId = chat._id
BE-->>FE: { dispute } BE-->>FE: { dispute }
FE-->>B: chat opens (real-time via existing chat join) Note over IO: ⚠️ No socket events fire (TODO stubs)
FE-->>S: chat opens (real-time via existing chat join)
A->>FE: Admin dashboard, click "Pick up" A->>FE: Admin dashboard, click "Pick up"
FE->>BE: POST /api/disputes/{id}/assign FE->>BE: POST /api/disputes/{id}/assign
Note right of BE: ⚠️ No role guard
BE->>DB: dispute.adminId, status="in_progress", timeline.push BE->>DB: dispute.adminId, status="in_progress", timeline.push
BE->>DB: chat.participants.push(admin) BE->>DB: chat.participants.push(admin)
BE-->>FE: { dispute } BE-->>FE: { dispute }
Note over IO: ⚠️ No socket events fire (TODO stubs)
loop investigation loop investigation
A->>FE: Chat with B & S A->>FE: Chat with B & S
B-->>BE: POST /api/disputes/{id}/evidence (image) B-->>BE: POST /api/disputes/{id}/evidence (image)
BE->>DB: dispute.evidence.push, timeline.push BE->>DB: dispute.evidence.push, timeline.push
Note over IO: ⚠️ No socket events fire (TODO stubs)
end end
A->>FE: Click "Resolve" choose action A->>FE: Click "Resolve" choose action
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes } FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? }
BE->>DB: dispute.status="resolved", resolution={...} Note right of BE: ⚠️ No role guard (dashboard router)
BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...}
alt action="refund" alt action="refund"
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]] A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
else action="release" else action="replacement"
A->>BE: trigger payout to seller\n[[Payout Flow]] A->>BE: arrange replacement item (manual)
else action="partial" else action="compensation"
A->>BE: split — refund X to buyer, release Y to seller A->>BE: partial payment to buyer (manual)
else action="warning_seller" / "ban_seller"
A->>BE: admin account action (manual)
else action="no_action"
A->>BE: dismiss dispute
end end
BE-->>FE: { dispute } BE-->>FE: { dispute }
IO-->>B: 'new-notification' dispute resolved (planned) Note over IO: ⚠️ No socket events fire (TODO stubs)
IO-->>S: 'new-notification' dispute resolved (planned)
``` ```
---
## API calls ## API calls
| Method | Endpoint | Source | ### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes`
|---|---|---|
| `POST` | `/api/disputes` | `disputeRoutes.ts:12``DisputeController.createDispute` |
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
All require `authenticateToken` (router-level middleware). | Method | Endpoint | Auth | Role Guard | Notes |
|---|---|---|---|---|
| `POST` | `/api/disputes` | `authenticateToken` | None | Create dispute |
| `GET` | `/api/disputes` | `authenticateToken` | None | List with filters |
| `GET` | `/api/disputes/statistics` | `authenticateToken` | None | Aggregate stats |
| `GET` | `/api/disputes/:id` | `authenticateToken` | None | Get by ID |
| `POST` | `/api/disputes/:id/assign` | `authenticateToken` | **MISSING** ⚠️ | Self-assign possible |
| `PATCH` | `/api/disputes/:id/status` | `authenticateToken` | **MISSING** ⚠️ | Any user can change status |
| `POST` | `/api/disputes/:id/resolve` | `authenticateToken` | **MISSING** ⚠️ | Any user can resolve |
| `POST` | `/api/disputes/:id/evidence` | `authenticateToken` | None | Add evidence |
### Release-hold router (`backend/src/services/dispute/disputeRoutes.ts`) — mounted second at `/api/disputes`
| Method | Endpoint | Auth | Role Guard | Notes |
|---|---|---|---|---|
| `POST` | `/api/disputes/:purchaseRequestId/raise` | `authenticateToken` | Buyer or admin (inline check) | Sets hold fields on PurchaseRequest |
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | `authenticateToken` | `authorizeRoles('admin')` ✓ | Clears hold fields |
| `GET` | `/api/disputes/:purchaseRequestId/status` | `authenticateToken` | Participant or admin (inline check) | Returns hold/block status |
> [!warning] Route shadowing: `POST /api/disputes/:id/resolve` in the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router's `POST /:purchaseRequestId/resolve` (has guard). See [Route Shadowing](#route-shadowing).
---
## Database writes ## Database writes
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle. - **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout. - **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]]. - **`purchaserequests`** — hold fields (`disputeRaised`, `disputeRaisedAt`, `disputeResolved`, `disputeResolvedAt`, `disputeHoldReason`, `holdUntil`) mutated by the release-hold service. Not touched by `DisputeService` directly.
- **`payments`** — touched indirectly when the admin performs the financial resolution. - **`payments`** — touched indirectly when the admin performs the financial resolution.
- **`notifications`** — `TODO` markers in code; planned addition. - **`notifications`** — TODO; no writes happen today.
## Socket events emitted ## Socket events emitted
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`). > [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub.
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
Planned events (not yet implemented):
- **`new-notification`** → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, and resolution.
- **`dispute-updated`** → planned but not implemented.
The only real-time activity in the dispute flow today is through the standard **Chat** socket (`new-message` on `chat-{disputeChatId}`) when participants send chat messages — this flows through `ChatService.sendMessage`, which is separate from the dispute service and does emit.
## Side effects ## Side effects
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room. - **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance. - **Timeline append-only log** is the audit trail. The pre-save hook auto-appends `dispute_created` on insert. Surface this in the admin UI for compliance.
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority. - **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention. - **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
## Error / edge cases ## Error / edge cases
- **Purchase request missing** → `400 Purchase request not found`. - **Purchase request missing** → `400 Purchase request not found`.
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations. - **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer only, no seller). Recommended: reject creation in this case to avoid mediator-less situations.
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening). - **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates. - **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding a unique index on `(purchaseRequestId, status)` filtered to `pending|in_progress` to prevent duplicates.
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence. - **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state. Add automation that auto-fires the payout/refund when the admin selects `release` or `refund`. - **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution action.
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`. - **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
- **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation.
> [!tip] Sort disputes by priority + age > [!tip] Sort disputes by priority + age
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort. > The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
@@ -189,18 +309,17 @@ All require `authenticateToken` (router-level middleware).
- [[Chat Flow]] — message-level mechanics inside the dispute chat. - [[Chat Flow]] — message-level mechanics inside the dispute chat.
- [[Escrow Flow]] — the financial state being contested. - [[Escrow Flow]] — the financial state being contested.
- [[Payout Flow]] — executed on `release` resolutions. - [[Payout Flow]] — executed on `refund` / `compensation` resolutions.
- [[Notification Flow]] — channels for dispute alerts. - [[Notification Flow]] — channels for dispute alerts (not yet wired).
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery. - [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
## Source files ## Source files
> [!warning] Not implemented - `backend/src/services/dispute/DisputeService.ts` — core service logic
> None of the backend files below exist as of 2026-05-24. The dispute module is planned but not yet built. - `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve)
- `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers
- Backend: `backend/src/services/dispute/DisputeService.ts` *(planned)* - `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards)
- Backend: `backend/src/controllers/disputeController.ts` *(planned)* - `backend/src/models/Dispute.ts` — canonical schema and enums
- Backend: `backend/src/routes/disputeRoutes.ts` *(planned)* - `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk)
- Backend: `backend/src/models/Dispute.ts` *(planned)* - `frontend/src/sections/request/components/report-problem-to-admin.tsx`
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx` - `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)

View File

@@ -1,199 +1,278 @@
--- ---
title: Escrow Flow title: Escrow Flow
tags: [flow, escrow, payment, state-machine] tags: [flow, escrow, payment, state-machine, custody]
related_models: ["[[Payment]]", "[[PurchaseRequest]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
related_apis: ["POST /api/payment/release/:paymentId", "POST /api/payment/refund/:paymentId"] related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/refund", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund/confirm"]
--- ---
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: `POST /api/disputes/:id/resolve` clarified as Dispute-document-only — it does NOT move escrow funds; route shadowing between the two dispute routers documented; `confirm-delivery` authorization gap flagged.
# Escrow Flow # Escrow Flow
The escrow is not a separate smart contract — it is a **state machine on the `Payment` document** combined with a **custodial wallet** (the platform-controlled BSC address `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS`). Funds sit at that wallet once SHKeeper / Web3 verification completes, and are released to the seller or refunded to the buyer based on order outcome. The current escrow is a **hybrid custody system**, not a custom Solidity escrow contract.
Buyer funds move on-chain through Request Network-compatible wallet transactions. The backend verifies the payment through signed Request Network webhooks/reconciliation plus the Transaction Safety Provider, records state in `Payment`, and records money movement in the internal funds ledger. Release/refund/sweep actions are still administered by the platform, with optional Trezor proof today and a recommended move to Safe multisig custody in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## Actors ## Actors
- **System** — the backend, on receiving pay-in confirmation. - **Buyer** -- pays from their wallet and confirms delivery.
- **Buyer** — confirms delivery to authorise release; can open a dispute to block release. - **Seller** -- fulfills the order and receives release.
- **Seller** recipient of release. - **Admin / mediator** -- resolves disputes and initiates release/refund when manual action is required.
- **Admin** — resolves disputes and signs payout transactions when manual control is required. - **Custody signer** -- Trezor today when enabled; target state is Safe multisig owners.
- **MongoDB** — `payments` document holds the canonical `escrowState`. - **Request Network** -- emits payment evidence through signed webhooks and status APIs.
- **Transaction Safety Provider** -- verifies tx hash, confirmations, recipient, token, amount, and optional AML decision before funds are credited.
- **MongoDB** -- stores `Payment`, `FundsLedgerEntry`, `Dispute`, and `PurchaseRequest` state.
## Escrow state machine (`Payment.escrowState`) ## Current State Model
Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed | cancelled | partial`. `Payment.status` remains the coarse provider/business state:
- `pending`
- `processing`
- `confirmed`
- `completed`
- `failed`
- `cancelled`
- `refunded`
`Payment.escrowState` currently supports:
- `funded`
- `releasable`
- `releasing`
- `released`
- `refunded`
- `failed`
- `cancelled`
- `partial`
The current model also has `Payment.disputed`, `disputeHoldReason`, and `holdUntil`. The canonical target state machine in [[Funds Ledger and Escrow State Machine Specification]] adds explicit `DISPUTED`, `REFUNDING`, and normalized uppercase enums. Treat that spec as the destination; this page describes the live hybrid implementation.
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Pending: Payment.status="pending"\nescrowState=undefined [*] --> Pending : payment intent created
Pending --> Partial: webhook PARTIAL\nescrowState="partial" Pending --> Processing : funds detected / webhook received
Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded" Pending --> Cancelled : intent expired or buyer cancels
Partial --> Funded: top-up reaches threshold
Funded --> Releasable: buyer confirms delivery\n(or auto-release timer) Processing --> Funded : Transaction Safety Provider approved
Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]] Processing --> Failed : verification rejected
Releasing --> Released: payout tx confirmed\nescrowState="released"
Releasing --> Failed: payout tx reverted\nescrowState="failed" Funded --> Releasable : delivery confirmed / release authorized
Funded --> Refunded: dispute resolution = refund\nescrowState="refunded" Funded --> DisputeHold : dispute opened
Funded --> Refunded: order cancelled\npre-shipment Releasable --> DisputeHold : dispute opened before payout
Pending --> Cancelled: webhook EXPIRED/CANCELLED
escrowState="cancelled" DisputeHold --> Funded : dispute rejected / no financial action
Failed --> Releasing: admin retries DisputeHold --> Releasable : resolved for seller
DisputeHold --> Refunding : resolved for buyer
Releasable --> Releasing : release instruction built
Releasing --> Released : tx hash confirmed
Releasing --> Failed : payout failed
Refunding --> Refunded : refund tx hash confirmed
Refunding --> Failed : refund failed
Failed --> Releasing : admin retries release
Failed --> Refunding : admin retries refund
Released --> [*] Released --> [*]
Refunded --> [*] Refunded --> [*]
Cancelled --> [*] Cancelled --> [*]
``` ```
`Payment.status` mirrors a coarser business state: ## Step-by-step Narrative
- `pending` → invoice issued, awaiting funds.
- `processing` → SHKeeper sees partial / confirmations in progress.
- `confirmed` → fully credited (intermediate; sometimes skipped).
- `completed` → escrow `funded` and onward.
- `failed`, `cancelled`, `refunded` → terminal.
## Step-by-step narrative
### 1. Funding ### 1. Funding
- Triggered by either [[Payment Flow - SHKeeper]] (webhook `PAID`/`OVERPAID`) or [[Payment Flow - DePay & Web3]] (verified `eth_getTransactionReceipt`). 1. Buyer accepts a seller offer and starts Request Network checkout.
- Backend sets `Payment.status = "completed"` and `Payment.escrowState = "funded"` (`shkeeperWebhook.ts:388-391`, `shkeeperService.ts:600-602`). 2. Backend creates a `Payment` and Request Network intent through `requestNetworkPayInService.ts`.
- Cascade: `PurchaseRequest.status``payment`, then `processing` once the seller acknowledges; `SellerOffer.status``accepted`; chat created. 3. When configured, `getDestinationFor({ buyerId, sellerOfferId, chainId })` assigns a per-payment derived destination and stores it in `payment.metadata.derivedDestination`.
- Funds physically sit at the **custodial wallet** — SHKeeper's per-invoice deposit address (auto-swept to the merchant wallet) or directly at the escrow wallet in the Web3 path. 4. Frontend renders the in-house checkout block and the buyer signs RN-compatible on-chain transactions from their wallet.
5. Request Network webhook or reconciliation reports payment evidence.
6. The Transaction Safety Provider verifies:
- transaction hash exists,
- chain confirmations meet the runtime/env threshold,
- token, recipient, and amount match,
- AML/sanctions provider result when configured.
7. Only after safety approval does the backend mark the payment funded and append ledger entries.
### 2. Holding ### 2. Holding
- While `escrowState === "funded"` and the order is in `processing` / `delivery`, the funds are inert. No interest accrues; no on-chain action happens. While escrow is funded, funds are represented in two places:
- The buyer cannot withdraw; the seller cannot collect. Only an admin/system action moves it forward.
- Visible in admin dashboard: `GET /api/payment/admin/funded?status=funded` (or similar — see admin payment view in `frontend/src/sections/payment/view/payment-list-admin-view.tsx`).
### 3. Releasing (happy path) - **On chain:** in the derived destination or custody wallet until swept/released/refunded.
- **In app accounting:** in `FundsLedgerEntry` rows and `Payment.escrowState`.
- Trigger options: Release/refund eligibility must be derived from ledger availability, not raw mutable `Payment.status` alone. In production the roadmap requires `PAYMENT_LEDGER_ENFORCEMENT=true` before custody decentralization.
- **Buyer confirms delivery** via the delivery-code flow ([[Delivery Confirmation Flow]]).
- **Auto-release timer** elapses (configurable; today a manual or scheduled job — `PurchaseRequestService` exposes status transitions through to `completed`).
- **Admin manual release** from the admin payment detail view.
- The system marks `Payment.escrowState = "releasable"` (intermediate).
- `shkeeperPayoutService.createPayoutTask` (or a manual EVM admin signature via `admin-wallet-payout.tsx`) starts the on-chain transfer to the seller's verified wallet address. State flips to `releasing`.
- On confirmation: `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets:
- `Payment.status = 'completed'`
- `Payment.escrowState = 'released'`
- `Payment.blockchain.transactionHash = <payout tx hash>`
- Cascade: `PurchaseRequest.status``seller_paid` then `completed`.
### 4. Refunding (dispute / cancellation) ### 3. Release
- Trigger: dispute resolution with `action: 'refund'` or pre-shipment cancellation. Release is triggered by delivery confirmation, auto-release policy, or dispute resolution for the seller.
- Backend builds the refund tx via `buildAdminSignedTxPayload(paymentId, 'refund')` (`shkeeperService.ts:614-626`) — destination is `payment.blockchain.sender` (the buyer's verified wallet).
- Admin signs and broadcasts (currently a manual step in the admin UI).
- On confirmation: `confirmAdminTx(paymentId, txHash, 'refund')` sets:
- `Payment.status = 'refunded'`
- `Payment.escrowState = 'refunded'`
- Cascade: `PurchaseRequest.status``cancelled` (or remains in dispute-resolved state).
### 5. Failed payout 1. Admin calls `POST /api/payment/:id/release`.
2. Backend loads the payment and validates ledger availability when enforcement is enabled.
3. Backend builds a provider payment instruction.
4. Custody signer executes the transaction:
- current optional control: Trezor proof when `TREZOR_SAFEKEEPING_REQUIRED=true`;
- roadmap control: Safe multisig transaction proposal/execution.
5. Admin confirms with `POST /api/payment/:id/release/confirm` and tx hash.
6. Backend validates Trezor proof when required, confirms adapter state, and appends a `release` ledger entry.
- If the payout tx reverts (insufficient gas, contract pause, wrong address), `escrowState = 'failed'`. Admin can retry by initiating a fresh payout. ### 4. Refund
## Sequence diagram (release path) Refund follows the same instruction/confirmation pattern as release, but destination is the buyer/refund wallet and ledger entry type is `refund`.
Refund can be triggered by dispute resolution for the buyer, pre-fulfillment cancellation, or an admin/manual recovery flow. A refund during an active dispute must be an explicit resolution path, not an accidental bypass.
### 5. Dispute Hold
Opening a dispute now has backend support through `releaseHoldService.ts`: it sets hold fields on the related purchase request and payments, and release/refund gates consult those holds.
Remaining alignment work:
- migrate from legacy dispute status enum to the canonical spec,
- make financial side effects automatic from final dispute resolution,
- ensure every release/refund path calls the same policy service,
- record immutable audit entries for dispute resolution and custody execution.
### 6. Dispute Resolution and Escrow Funds
> [!warning] Two different handlers share the same path — they do different things
>
> There are **two dispute routers** both mounted at `/api/disputes`. This creates route shadowing:
>
> | Handler | What it does |
> |---|---|
> | Dashboard dispute router: `POST /api/disputes/:id/resolve` | Updates the **Dispute document only** — changes dispute status, records resolution notes, etc. **Does NOT touch escrow funds.** |
> | releaseHold router: `POST /api/disputes/:purchaseRequestId/resolve` | Unblocks escrow — removes the dispute hold from the `Payment` and `PurchaseRequest`, making the escrow eligible for release or refund. |
>
> Because the dashboard router is mounted first, a `POST /api/disputes/{id}/resolve` request will be handled by the dashboard router's `POST /:id/resolve` handler if the supplied ID matches a dispute document ID. If the intent is to unblock escrow funds, the correct target is the releaseHold router, but route registration order means the dashboard router intercepts the call first. This is a **route shadowing bug** — both routers claim the same URL pattern and the outcome depends entirely on registration order.
>
> In practice: calling `POST /api/disputes/:id/resolve` alone is **not sufficient to release or refund escrow**. The escrow unblock is only guaranteed when the releaseHold handler is reached. Verify router mount order in `backend/src/services/dispute/` before relying on either path in automation or admin tooling.
### 7. Delivery Confirmation Authorization Gap
> [!warning] ⚠️ Known authorization gap — `confirm-delivery`
>
> The `PATCH /api/marketplace/purchase-requests/:id/confirm-delivery` endpoint has **no authorization guard**. Any authenticated user (not just the buyer who owns the request) can call this endpoint and advance the purchase request status to `delivered`. This is a known gap and should be remediated by adding an ownership check (`req.user._id === purchaseRequest.buyerId`) before processing the status transition.
## Sequence Diagram - Funding
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor B as Buyer actor B as Buyer
actor A as Admin
participant FE as Frontend participant FE as Frontend
participant BE as Backend participant BE as Backend
participant RN as Request Network
participant BC as EVM Chain
participant DB as MongoDB participant DB as MongoDB
participant SK as SHKeeper Payout API
participant BC as BSC
B->>FE: Enter delivery code (or auto-timer fires) B->>FE: Start Request Network checkout
FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery FE->>BE: POST /api/payment/request-network/intents
BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable" BE->>DB: Payment.create(status="pending")
BE-->>FE: ok BE->>BE: Assign derived destination when configured
A->>FE: Click "Release" in admin BE->>RN: Create Request Network intent
FE->>BE: POST /api/payment/shkeeper/payout BE-->>FE: inHouseCheckout block
BE->>DB: Payment.escrowState="releasing" B->>BC: approve + transferFromWithReferenceAndFee
BE->>SK: createPayoutTask({recipient, amount}) RN-->>BE: signed webhook / status evidence
SK->>BC: signed payout tx BE->>BE: Transaction Safety Provider checks
BC-->>SK: confirmed BE->>DB: Payment.status="completed", escrowState="funded"
SK->>BE: payout webhook / poll BE->>DB: append FundsLedgerEntry(payment_detected / hold)
BE->>BE: confirmAdminTx(paymentId, txHash, "release")
BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"
``` ```
## Sequence diagram (refund path) ## Sequence Diagram - Release / Refund
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor A as Admin actor A as Admin
actor C as Custody signer
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant BC as BSC participant BC as EVM Chain
actor B as Buyer
A->>BE: Dispute resolved with action="refund" A->>BE: POST /api/payment/{id}/release or refund
BE->>BE: buildAdminSignedTxPayload(paymentId, "refund") BE->>DB: Load Payment + ledger balance
BE-->>A: { to:buyerWallet, amount, token, network } BE->>BE: Check dispute hold + ledger availability
A->>BC: sign + broadcast tx BE-->>A: unsigned instruction
BC-->>A: txHash A->>C: Request signature / Safe execution
A->>BE: confirmAdminTx(paymentId, txHash, "refund") C->>BC: Broadcast tx
BE->>DB: Payment.status="refunded"\nescrowState="refunded" BC-->>C: txHash
BE->>B: notifyRefundCompleted A->>BE: POST /confirm { txHash, optional trezor proof }
BE->>BE: Verify signer proof when required
BE->>DB: append release/refund ledger entry
BE->>DB: escrowState="released" or "refunded"
``` ```
## API calls ## Sequence Diagram - Dispute Resolution (Escrow Path)
```mermaid
sequenceDiagram
autonumber
actor A as Admin / Mediator
participant DR as Dashboard Dispute Router\n(POST /api/disputes/:id/resolve)
participant RH as releaseHold Router\n(POST /api/disputes/:purchaseRequestId/resolve)
participant DB as MongoDB
participant ES as Escrow / Payment
Note over DR,RH: Both routers mounted at /api/disputes — dashboard router registered first
A->>DR: POST /api/disputes/{disputeId}/resolve
DR->>DB: Update Dispute document (status, notes)
DR-->>A: 200 OK (Dispute updated only)
Note over ES: Escrow funds still on hold at this point
A->>RH: POST /api/disputes/{purchaseRequestId}/resolve
RH->>DB: Remove hold from Payment + PurchaseRequest
RH->>ES: Escrow now eligible for release or refund
RH-->>A: 200 OK (Hold removed)
```
## API Calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose |
|---|---|---| |---|---|---|
| `POST` | `/api/payment/admin/release/:paymentId` | Initiate release | | `POST` | `/api/payment/request-network/intents` | Create Request Network pay-in intent |
| `POST` | `/api/payment/admin/refund/:paymentId` | Initiate refund | | `GET` | `/api/payment/request-network/:paymentId/checkout` | Rehydrate in-house checkout block |
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Admin marks the signed tx confirmed | | `POST` | `/api/payment/request-network/webhook` | Receive signed RN webhook |
| `GET` | `/api/payment/:paymentId/status` | Polled by both parties | | `POST` | `/api/payment/:id/release` | Build release instruction |
| `POST` | `/api/payment/:id/release/confirm` | Confirm release tx hash / signer proof |
| `POST` | `/api/payment/:id/refund` | Build refund instruction |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm refund tx hash / signer proof |
| `GET` | `/api/payment/:id` | Read payment details |
| `GET` | `/api/payment/derived-destinations` | Admin list of derived destinations |
| `POST` | `/api/disputes/:id/resolve` | Update Dispute document only — does NOT touch escrow |
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | Remove dispute hold from escrow (releaseHold router) — see shadowing note above |
## Database writes ## Side Effects And Risks
- **`payments`**: `status`, `escrowState`, `blockchain.transactionHash`, `completedAt`, `metadata.*` are mutated as the state progresses. - **No custom on-chain escrow contract yet.** This is deliberate; [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] recommends Safe/Trezor custody controls before a custom contract pilot.
- **`purchaserequests`**: `status` cascades (`payment → processing → delivery → delivered → confirming → seller_paid → completed`). - **Ledger enforcement is configurable.** `PAYMENT_LEDGER_ENFORCEMENT` must be enabled before real custody decentralization work is considered complete.
- **`notifications`**: created on each terminal state. - **Trezor enforcement is configurable.** `TREZOR_SAFEKEEPING_REQUIRED=true` makes Trezor proof mandatory for release/refund confirmation, but target custody should be Safe multisig.
- **Durable webhook ingress is still roadmap work.** Until the Worker/replay layer is live, backend availability remains important for Request Network webhook delivery.
- **Dispute model is implemented but not fully canonical.** The current model works with legacy enum names; canonical status alignment remains required.
- **Route shadowing on `/api/disputes`** — two routers registered at the same mount point. Dashboard router intercepts first; releaseHold handler may not be reachable by the expected URL in all configurations. See section 6 above.
- **`confirm-delivery` has no authorization guard** — any authenticated user can advance a purchase request to `delivered`. See section 7 above.
## Socket events emitted ## Linked Flows
- **`purchase-request-update`** `status-changed` on every cascading status flip. - [[PRD - Request Network In-House Checkout]] -- current primary pay-in path.
- **`payment-status`** (planned/admin) — admin dashboard real-time feed. - [[Dispute Flow]] -- can block or redirect escrow.
- [[Delivery Confirmation Flow]] -- happy-path release trigger.
- [[Payout Flow]] -- historical payout context and release mechanics.
- [[Trezor Safekeeping Flow]] -- hardware proof for admin actions.
- [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- custody decentralization and smart-contract decision plan.
## Side effects ## Source Files
- **Custodial risk** — the escrow wallet's private key sits with the platform. Lose it → lose all in-flight escrows. Operational controls: hardware wallet, multi-sig, cold storage of the recovery seed. - Backend: `backend/src/models/Payment.ts`
- **No on-chain escrow contract** — there is no Solidity escrow today. Migration toward a smart-contract escrow (e.g. OpenZeppelin's `Escrow.sol` pattern) would remove custodial trust at the cost of higher complexity and gas. - Backend: `backend/src/models/FundsLedgerEntry.ts`
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts`
## Error / edge cases - Backend: `backend/src/services/payment/safety/transactionSafetyProvider.ts`
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- **Buyer never confirms delivery** → today requires admin intervention. An auto-release timer (e.g. 7 days after `delivered`) is a recommended addition. - Backend: `backend/src/services/payment/wallets/derivedDestinations.ts`
- **Seller's wallet address invalid** → payout tx fails or sends to a black hole. Validate `recipientAddress` shape (`^0x[0-9a-fA-F]{40}$`) before signing (`shkeeperPayoutService.ts:62-64` checks `.startsWith('0x')`). - Backend: `backend/src/services/payment/wallets/sweepService.ts`
- **Partial payment** (`PARTIAL`) → escrow remains in `pending/partial`; release blocked until full payment arrives. - Backend: `backend/src/services/dispute/releaseHoldService.ts`
- **Overpaid** → currently treated as `completed/funded`; the surplus is not auto-refunded. - Backend: `backend/src/services/trezor/trezorService.ts`
- **Concurrent release + refund** → blocked by `PaymentCoordinator` serialisation; whichever fires first wins, the other is rejected.
- **Payout fails on chain** → state stays in `releasing` until admin re-runs; consider auto-retry with exponential backoff.
- **Disputed payment** → `escrowState` is **not** auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add a `disputed` boolean or `escrowState='disputed'` to make this more obvious.
> [!warning] Single custodial wallet = single point of failure
> Centralising all in-flight escrow at one BSC address is the platform's largest operational risk. Use a multi-sig (Gnosis Safe) for the escrow wallet, store one key in HSM, and require two admin signatures for any payout > a threshold.
> [!tip] Recovering inconsistent state
> If `Payment.escrowState` looks stale (e.g. `released` but no on-chain tx hash), inspect with `Payment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } })` and reconcile via the SHKeeper invoice or the `fix-transaction-hashes.js` script.
## Linked flows
- [[Payment Flow - SHKeeper]] — funds the escrow.
- [[Payment Flow - DePay & Web3]] — alternative funding path.
- [[Delivery Confirmation Flow]] — triggers release.
- [[Dispute Flow]] — can divert to refund.
- [[Payout Flow]] — executes the release transfer.
## Source files
- Backend: `backend/src/models/Payment.ts:96-145` (status + escrowState enums)
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:600-647`
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:387-411`
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
- Frontend: `frontend/src/sections/payment/view/payment-list-admin-view.tsx`
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
# Google OAuth Flow # Google OAuth Flow
> **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))
Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified. Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified.
## Actors ## Actors
@@ -33,8 +35,8 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`). 4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`).
5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`. 5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`.
6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer. 6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer.
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if found returns `409 USER_EXISTS` so the user can use *sign-in* instead. 7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if the email already exists, returns **`409 USER_EXISTS`** so the user can use *sign-in* instead.
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, role from the request. 8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, and the chosen `role` from the request.
9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`. 9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`.
10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`. 10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`.
11. Frontend stores tokens in `localStorage` and redirects to the dashboard. 11. Frontend stores tokens in `localStorage` and redirects to the dashboard.
@@ -44,12 +46,15 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
1. User clicks the Google icon on `/auth/jwt/sign-in`. 1. User clicks the Google icon on `/auth/jwt/sign-in`.
2. Same GSI flow as sign-up — Google returns an ID token. 2. Same GSI flow as sign-up — Google returns an ID token.
3. Frontend calls `signInWithGoogle(googleToken)``POST /api/auth/google/signin`. 3. Frontend calls `signInWithGoogle(googleToken)``POST /api/auth/google/signin`.
4. Backend verifies the token, looks up `User.findOne({ email: googleUser.email })`. If no user, returns `404 USER_NOT_FOUND` ("please sign up first"). The frontend surfaces a localized prompt. 4. Backend verifies the token, then looks up `User.findOne({ email: googleUser.email, status: "active" })` (`authController.ts:1194`). Note the **`status: "active"` filter**: the query only matches active accounts. If no active user matches, returns **`404 USER_NOT_FOUND`** ("please sign up first"). The frontend surfaces a localized prompt.
5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`). 5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`).
6. Tokens issued and returned identically to email login. 6. Tokens issued and returned identically to email login.
> [!tip] Account linking is implicit by email > [!warning] No account merge
> A user who originally signed up via email + password can sign in with Google as long as the email matches — no extra "link account" step. The backend simply reuses the existing user document. There is **no** separate `googleId` field stored today, so this is a one-way trust on `googleUser.email`. > There is **no** account-merge step between a Telegram-only / email account and a Google account. The Google sign-in path simply looks up an **active** user by email and reuses that document if one exists; it does not reconcile, link, or merge distinct identities. There is **no** separate `googleId` field stored today, so matching is a one-way trust on `googleUser.email`.
> [!warning] Soft-deleted accounts get a generic 404 on Google sign-in
> Because the sign-in lookup filters by `status: "active"`, a user who registered via Google and was later **soft-deleted** (`status: "deleted"`) is invisible to the query. They receive the **same generic `404 USER_NOT_FOUND`** as a never-registered user — there is **no** distinct "account deleted" / "account disabled" error.
## Sequence diagram ## Sequence diagram
@@ -76,15 +81,19 @@ sequenceDiagram
end end
BE->>GA: verifyGoogleToken(googleToken) BE->>GA: verifyGoogleToken(googleToken)
GA-->>BE: { email, name, picture, ... } or null GA-->>BE: { email, name, picture, ... } or null
alt Sign-up
BE->>DB: User.findOne({ email }) BE->>DB: User.findOne({ email })
alt Sign-up: user exists else Sign-in
BE->>DB: User.findOne({ email, status: "active" })
end
alt Sign-up: email exists
BE-->>FE: 409 USER_EXISTS BE-->>FE: 409 USER_EXISTS
else Sign-up: new else Sign-up: new
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar }) BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
opt referral opt referral
BE->>DB: increment referrer.referralStats BE->>DB: increment referrer.referralStats
end end
else Sign-in: user missing else Sign-in: no active user (missing or soft-deleted)
BE-->>FE: 404 USER_NOT_FOUND BE-->>FE: 404 USER_NOT_FOUND
else Sign-in: ok else Sign-in: ok
BE->>DB: set user.lastLoginAt = now BE->>DB: set user.lastLoginAt = now
@@ -120,8 +129,9 @@ sequenceDiagram
## Error / edge cases ## Error / edge cases
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null``401 INVALID_GOOGLE_TOKEN`. - **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null``401 INVALID_GOOGLE_TOKEN`.
- **User already exists during sign-up** → `409`; frontend prompts to use sign-in instead. - **Email already exists during sign-up** → `409 USER_EXISTS`; frontend prompts to use sign-in instead.
- **User missing during sign-in** → `404`; frontend redirects to sign-up. - **User does not exist during sign-in** → `404 USER_NOT_FOUND`; frontend redirects to sign-up.
- **Soft-deleted user signs in via Google** → `404 USER_NOT_FOUND` (generic, indistinguishable from "never registered") because the lookup filters by `status: "active"`.
- **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast. - **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast.
- **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click. - **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click.
- **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`. - **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`.

View File

@@ -2,11 +2,13 @@
title: Negotiation Flow title: Negotiation Flow
tags: [flow, marketplace, negotiation, counter-offer, chat] tags: [flow, marketplace, negotiation, counter-offer, chat]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"] related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"] related_apis: ["PUT /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
--- ---
# Negotiation Flow # Negotiation Flow
> **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))
After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject. After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject.
## Actors ## Actors
@@ -16,7 +18,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`. - **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`.
- **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip. - **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`. - **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`. - **Socket.IO** — `new-message`, `purchase-request-update`.
## Preconditions ## Preconditions
@@ -24,31 +26,40 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- The purchase request is `received_offers` or `in_negotiation`. - The purchase request is `received_offers` or `in_negotiation`.
- Both parties are still active users. - Both parties are still active users.
> [!info] Status vocabulary
> The negotiation drives the **PurchaseRequest** into the `in_negotiation` status. The **SellerOffer** moves only between `pending`, `accepted`, `rejected`, and `withdrawn` (`backend/src/models/SellerOffer.ts:80`). There is **no `'active'` SellerOffer status** — any documentation or UI that references an "active" offer is incorrect.
## Step-by-step narrative ## Step-by-step narrative
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view. 1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
> [!tip] Pre-payment chats vs. post-payment chats > [!tip] Pre-payment chats vs. post-payment chats
> A negotiation chat may exist **before** the SHKeeper webhook auto-creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates the same chat object is reused. > A negotiation chat may exist **before** payment confirmation creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates -- the same chat object is reused.
2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`). 2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`).
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used: 3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:
- **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price. - **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
- **Structured counter** — the buyer opens an "edit offer" modal that PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price. - **Structured counter** — the buyer opens an "edit offer" modal that (via the frontend `updateOffer` action) sends `PUT /api/marketplace/offers/{id}` with the new desired terms. This is a seller-side edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
4. **Seller updates the offer**`SellerOfferService.updateOffer` (`:271-295`): 4. **Seller updates the offer**`SellerOfferService.updateOffer` (`:271-295`):
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`. - `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh. - Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
5. **Buyer accepts** — clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook flips offer → `accepted` and request → `payment`. > [!bug] ⚠️ KNOWN BUG — PUT/PATCH method mismatch on offer edit
> The frontend `updateOffer` action (`frontend/src/actions/marketplace.ts:286-297`) sends **`PUT /marketplace/offers/:id`**, but the legacy backend router registers only **`PATCH /offers/:id`** (`backend/src/services/marketplace/routes.ts:1260`). No `PUT /offers/:id` handler is registered, so structured offer edits from the UI may **404**. Fix by aligning on a single method (register `PUT` on the backend, or switch the frontend to `PATCH`).
6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`. 5. **Buyer accepts** -- clicks "Accept this offer", which kicks off [[PRD - Request Network In-House Checkout]] with the selected `sellerOfferId`. Payment confirmation flips offer -> `accepted` and request -> `payment`.
7. **Seller withdraws**`withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible. 6. **Buyer rejects** — the frontend `rejectOffer` action calls `PUT /api/marketplace/offers/{id}/status` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
7. **Seller withdraws** — there is **no dedicated `/withdraw` endpoint** (see warning below). The only way to withdraw is `PUT /api/marketplace/offers/{id}/status` with `{ status: 'withdrawn' }` (`routes.ts:1914`). `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics. 8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics.
> [!warning] ⚠️ NOT IMPLEMENTED — `POST /api/marketplace/offers/:id/withdraw`
> No `POST .../offers/:id/withdraw` route is registered anywhere in the backend; calling it returns **404**. Withdrawal is performed exclusively through the status endpoint: `PUT /api/marketplace/offers/:id/status` with body `{ status: 'withdrawn' }`.
## Sequence diagram ## Sequence diagram
```mermaid ```mermaid
@@ -75,19 +86,24 @@ sequenceDiagram
BE->>DB: PurchaseRequest.status = "in_negotiation" BE->>DB: PurchaseRequest.status = "in_negotiation"
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed) BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
S->>FE_S: Open edit-offer modal, set new price S->>FE_S: Open edit-offer modal, set new price
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}} FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
BE->>DB: SellerOffer update BE->>DB: SellerOffer update (if PUT handled; else 404)
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated) BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
IO-->>FE_B: refresh offer card IO-->>FE_B: refresh offer card
alt Buyer accepts alt Buyer accepts
B->>FE_B: Click "Pay" [[Payment Flow - SHKeeper]] B->>FE_B: Click "Pay" -> [[PRD - Request Network In-House Checkout]]
Note over BE: Webhook PAID flips offer→accepted, request→payment Note over BE: Webhook PAID flips offer→accepted, request→payment
else Buyer rejects else Buyer rejects
B->>FE_B: Click "Reject" B->>FE_B: Click "Reject"
FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"} FE_B->>BE: PUT /api/marketplace/offers/{id}/status {status:"rejected"}
BE->>DB: offer.status = "rejected" BE->>DB: offer.status = "rejected"
BE->>BE: notifyOfferRejected(seller) BE->>BE: notifyOfferRejected(seller)
IO-->>FE_S: 'new-notification' IO-->>FE_S: 'new-notification'
else Seller withdraws
S->>FE_S: Click "Withdraw offer"
FE_S->>BE: PUT /api/marketplace/offers/{id}/status {status:"withdrawn"}
BE->>DB: offer.status = "withdrawn"
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
end end
``` ```
@@ -97,14 +113,16 @@ sequenceDiagram
|---|---|---| |---|---|---|
| `POST` | `/api/chat` | Find-or-create negotiation chat | | `POST` | `/api/chat` | Find-or-create negotiation chat |
| `POST` | `/api/chat/:chatId/messages` | Send chat message | | `POST` | `/api/chat/:chatId/messages` | Send chat message |
| `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) | | `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer (scoped) — `routes.ts:1163` |
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | List offers for a request (scoped) — `routes.ts:1223` |
| `PUT` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter). ⚠️ KNOWN BUG: frontend sends `PUT`, backend registers only `PATCH /offers/:id` (`routes.ts:1260`) → may 404. |
| `PUT` | `/api/marketplace/offers/:id/status` | Reject (`{ status: 'rejected' }`) and withdraw (`{ status: 'withdrawn' }`) — `routes.ts:1914`. There is no separate `/withdraw` endpoint. |
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` | | `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
## Database writes ## Database writes
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants. - **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants.
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`. - **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`; status moves between `pending`/`accepted`/`rejected`/`withdrawn`.
- **`purchaserequests`**: status flips when first counter arrives. - **`purchaserequests`**: status flips when first counter arrives.
- **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel). - **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel).
@@ -122,9 +140,11 @@ sequenceDiagram
## Error / edge cases ## Error / edge cases
- **Offer edit returns 404** — see the KNOWN BUG above (PUT vs PATCH method mismatch).
- **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`). - **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`).
- **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner). - **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner).
- **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`. - **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`.
- **Withdraw after accept/reject** → `withdrawOffer` only acts while `status === 'pending'`, so withdrawal is rejected once the offer leaves that state.
- **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`). - **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`).
- **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue. - **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue.
- **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo. - **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo.
@@ -135,14 +155,17 @@ sequenceDiagram
## Linked flows ## Linked flows
- [[Seller Offer Flow]] — the prior step. - [[Seller Offer Flow]] — the prior step.
- [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment. - [[PRD - Request Network In-House Checkout]] — closes the negotiation with an on-chain payment.
- [[Chat Flow]] — message-level mechanics, attachments, read receipts. - [[Chat Flow]] — message-level mechanics, attachments, read receipts.
- [[Notification Flow]] — accept/reject notifications. - [[Notification Flow]] — accept/reject notifications.
## Source files ## Source files
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353` - Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-443`
- Backend: `backend/src/services/marketplace/routes.ts:1163-1278,1914` (offer routes)
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495` - Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
- Backend: `backend/src/services/chat/ChatService.ts:90-260` - Backend: `backend/src/services/chat/ChatService.ts:90-260`
- Backend: `backend/src/models/SellerOffer.ts:17,80` (status enum)
- Frontend: `frontend/src/actions/marketplace.ts:286-308` (`updateOffer`, `rejectOffer`)
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
- Frontend: `frontend/src/sections/chat/` (chat UI) - Frontend: `frontend/src/sections/chat/` (chat UI)

View File

@@ -2,9 +2,11 @@
title: Notification Flow title: Notification Flow
tags: [flow, notification, socket-io, email] tags: [flow, notification, socket-io, email]
related_models: ["[[Notification]]", "[[User]]"] related_models: ["[[Notification]]", "[[User]]"]
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "POST /api/notifications/read-all", "DELETE /api/notifications/:id"] related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "PATCH /api/notifications/mark-all-read", "DELETE /api/notifications/:id"]
--- ---
> **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))
# Notification Flow # Notification Flow
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**. Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
@@ -27,7 +29,7 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
- **User** — the recipient. - **User** — the recipient.
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider. - **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`. - **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
- **MongoDB** — `notifications` collection (one document per notification). - **MongoDB** — `notifications` collection (one document per notification). Notifications are **auto-deleted after 90 days** (TTL index on `createdAt`).
- **Socket.IO** — emits `new-notification` to `user-{userId}`. - **Socket.IO** — emits `new-notification` to `user-{userId}`.
- **Email** (optional) — periodic digest worker (not implemented today; planned). - **Email** (optional) — periodic digest worker (not implemented today; planned).
@@ -58,10 +60,14 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
### Reading ### Reading
8. User opens the bell-icon dropdown — frontend calls `POST /api/notifications/mark-read` for each viewed entry (or `POST /api/notifications/read-all`). 8. User opens the bell-icon dropdown — frontend calls `PATCH /api/notifications/:id/read` for each viewed entry, or `PATCH /api/notifications/mark-all-read` to clear all at once.
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`): 9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`. - `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
- Emits `notification-read` (or recomputes unread count) so other open tabs sync. - After updating, the backend emits `unread-count-update` to `user-{userId}` so all open tabs (and other devices) immediately sync their badge counter.
### Purchase request status coverage gap
`NotificationService.notifyRequestStatusChanged` handles many purchase-request statuses but does **not** emit notifications for `pending_payment` or `seller_paid`. If a buyer moves to `pending_payment` or a seller is marked `seller_paid`, no notification is created. This is a known coverage gap; add dedicated helper methods (or extend the switch-case) if those transitions need to surface to recipients.
### Preferences ### Preferences
@@ -97,27 +103,34 @@ sequenceDiagram
U->>FE: click notification U->>FE: click notification
FE->>NS: PATCH /api/notifications/{id}/read FE->>NS: PATCH /api/notifications/{id}/read
NS->>DB: Notification.findOneAndUpdate(isRead:true) NS->>DB: Notification.findOneAndUpdate(isRead:true)
NS->>IO: emit user-{userId} 'unread-count-update'
IO-->>FE: badge sync across tabs
FE-->>U: badge--, mark item as read FE-->>U: badge--, mark item as read
FE-->>U: navigate to notification.actionUrl FE-->>U: navigate to notification.actionUrl
``` ```
## API calls ## API calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose | Notes |
|---|---|---| |---|---|---|---|
| `GET` | `/api/notifications` | Paginated list with `unreadCount` | | `GET` | `/api/notifications` | Paginated list with `unreadCount` | |
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge | | `GET` | `/api/notifications/unread-count` | Just the unread count for badge | |
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read | | `GET` | `/api/notifications/:id` | Single notification | ⚠️ **Known bug** — see below |
| `POST` | `/api/notifications/read-all` | Mark all read | | `PATCH` | `/api/notifications/:id/read` | Mark single notification read | |
| `DELETE` | `/api/notifications/:id` | Remove from list | | `PATCH` | `/api/notifications/mark-all-read` | Mark all notifications read | Previously documented incorrectly as `POST /api/notifications/read-all` |
| `DELETE` | `/api/notifications/:id` | Remove from list | |
> ⚠️ **Known bug — `GET /api/notifications/:id`**: The backend controller does **not** perform a direct DB lookup by ID. Instead it calls `getUserNotifications(userId, 1, 1)` (fetches only 1 record for the user) and then does an in-memory `_id` comparison. Any notification that is not the user's single most-recent record will return `404` erroneously. Do not rely on this endpoint for arbitrary notification lookups until the controller is fixed to use a direct `findOne({ _id, userId })`.
## Database writes ## Database writes
- **`notifications`** — insert on create, update on read, delete on remove. - **`notifications`** — insert on create, update on read, delete on remove.
- **TTL**: notifications are automatically deleted after **90 days** via a MongoDB TTL index on `createdAt`.
## Socket events emitted ## Socket events emitted
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch). - **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
- **`unread-count-update`** → `user-{userId}`. Emitted whenever the unread count changes (e.g. after `markAsRead` or `markAllRead`). Used for cross-tab and cross-device badge synchronisation. There is **no** `notification-read` event — `unread-count-update` is the correct event to listen to for badge sync.
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`. - **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
- **`referral-signup`** → `user-{referrerId}` from auth verify. - **`referral-signup`** → `user-{referrerId}` from auth verify.
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge). - **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
@@ -131,10 +144,11 @@ sequenceDiagram
## Error / edge cases ## Error / edge cases
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay). - **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update. - **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update. Badge sync is driven by `unread-count-update`, not a per-item `notification-read` event.
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast). - **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case. - **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open. - **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
- **90-day TTL** → notifications older than 90 days are silently removed from MongoDB. The frontend should not assume a notification persists indefinitely.
> [!tip] Always set `actionUrl` > [!tip] Always set `actionUrl`
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers. > Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
@@ -152,4 +166,4 @@ sequenceDiagram
- Backend: `backend/src/services/notification/routes.ts` - Backend: `backend/src/services/notification/routes.ts`
- Backend: `backend/src/models/Notification.ts` - Backend: `backend/src/models/Notification.ts`
- Frontend: `frontend/src/layouts/components/notifications-drawer/` - Frontend: `frontend/src/layouts/components/notifications-drawer/`
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification`) - Frontend: socket provider (joins `user-{id}` and listens for `new-notification` and `unread-count-update`)

View File

@@ -7,7 +7,9 @@ related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/pass
# Passkey (WebAuthn) Flow # Passkey (WebAuthn) Flow
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow. > **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))
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, cryptographically validates attestations and assertions via `@simplewebauthn/server`, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
## Actors ## Actors
@@ -24,6 +26,7 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`). - For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user. - For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client. - Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
- **Important:** `next.config.ts` rewrites `/api/:path*` directly to the Express backend. There are **no** Next.js API route handler files for passkey paths — calls go straight to Express. Configure `PASSKEY_RP_ORIGIN` (and the corresponding `NEXT_PUBLIC_*` vars) to the frontend origin so the Express handler and the browser agree on the expected origin during challenge verification.
## Registration flow ## Registration flow
@@ -38,13 +41,11 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`): 6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use). - Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
- Loads `User.findById(userId)`. - Loads `User.findById(userId)`.
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`. - Calls `verifyRegistrationResponse()` from `@simplewebauthn/server`, which cryptographically validates the attestation object and extracts the COSE public key.
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: Buffer.from(webAuthnCredential.publicKey).toString('base64url'), counter: webAuthnCredential.counter, deviceType, deviceName, createdAt: now }`.
- Saves. - Saves.
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry. 7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
> [!warning] Attestation validation is stubbed
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
## Authentication flow ## Authentication flow
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**. 1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
@@ -56,8 +57,10 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`): 7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
- Confirms the challenge exists (and deletes it). - Confirms the challenge exists (and deletes it).
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator. - `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one). - Calls `verifyAuthenticationResponse()` from `@simplewebauthn/server`, passing the stored base64url-encoded COSE public key. This cryptographically verifies the signature over the authenticator data + client data hash.
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens. - Updates `passkey.counter` with the verified counter value returned by the library.
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). These are signed by the same `config.jwtSecret` as `authService`, so they are interchangeable with password-issued tokens.
- Persists the refresh token: `user.refreshTokens.push(refreshToken); await user.save()` (`:281-282`). The standard `/api/auth/refresh-token` endpoint will accept passkey-issued tokens.
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`. 8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
9. Frontend stores tokens in `localStorage` and redirects to the dashboard. 9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
@@ -79,10 +82,10 @@ sequenceDiagram
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
BE-->>FE: { challenge, rpId, ... } BE-->>FE: { challenge, rpId, ... }
FE->>W: navigator.credentials.create({ publicKey }) FE->>W: navigator.credentials.create({ publicKey })
W-->>FE: PublicKeyCredential W-->>FE: PublicKeyCredential (attestation)
FE->>BE: POST /api/auth/passkey/register { challenge, credential } FE->>BE: POST /api/auth/passkey/register { challenge, credential }
BE->>BE: verifyRegistration → consume challenge BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted
BE->>DB: user.passkeys.push({ id, counter, deviceType }) BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType })
BE-->>FE: { success: true } BE-->>FE: { success: true }
end end
@@ -98,7 +101,8 @@ sequenceDiagram
BE->>BE: consume challenge BE->>BE: consume challenge
BE->>DB: User.findOne({ 'passkeys.id': assertion.id }) BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
DB-->>BE: user with matching passkey DB-->>BE: user with matching passkey
BE->>DB: passkey.counter += 1 BE->>BE: verifyAuthenticationResponse() — signature verified\nagainst stored COSE public key
BE->>DB: passkey.counter updated\nuser.refreshTokens.push(refreshToken)
BE->>BE: jwt.sign(access) / jwt.sign(refresh) BE->>BE: jwt.sign(access) / jwt.sign(refresh)
BE-->>FE: { success, user, tokens } BE-->>FE: { success, user, tokens }
FE->>FE: localStorage.setItem(tokens) FE->>FE: localStorage.setItem(tokens)
@@ -119,8 +123,8 @@ sequenceDiagram
## Database writes ## Database writes
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete. - **`users.passkeys`** — append on register (stores real base64url-encoded COSE public key), increment `counter` on each successful auth, splice on delete.
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases. - **`users.refreshTokens`** — the passkey authentication path pushes the new refresh token into `user.refreshTokens[]` (`passkeyService.ts:281-282`) and saves the document. Passkey-issued refresh tokens are valid for the standard `/api/auth/refresh-token` endpoint.
## Socket events emitted ## Socket events emitted
@@ -138,16 +142,12 @@ sequenceDiagram
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry. - **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead. - **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis. - **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
- **Replay** — current implementation does not strictly enforce monotonic counter; revisit before production. - **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays.
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
> [!warning] Production hardening checklist > [!note] Production hardening checklist
> 1. Replace stub attestation parsing with `@simplewebauthn/server`. > 1. Move challenge storage to Redis to support multi-instance deploys.
> 2. Persist the COSE public key, not a stub string. > 2. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not). > 3. Ensure `PASSKEY_RP_ORIGIN` matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).
> 4. Move challenge storage to Redis to support multi-instance deploys.
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
## Linked flows ## Linked flows

View File

@@ -2,12 +2,24 @@
title: Password Reset Flow title: Password Reset Flow
tags: [flow, auth, password-reset, email] tags: [flow, auth, password-reset, email]
related_models: ["[[User]]"] related_models: ["[[User]]"]
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"] related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code", "POST /api/auth/reset-password"]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit note — last reviewed 2026-05-29
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
# Password Reset Flow # Password Reset Flow
Self-service password recovery: request a 6-digit code by email, submit it with the new password. Self-service password recovery. There are **two separate reset endpoints** with different security characteristics:
| Endpoint | Mechanism | Password complexity enforced? |
|---|---|---|
| `POST /api/auth/reset-password-with-code` | 6-digit emailed code | **No** — no validation middleware |
| `POST /api/auth/reset-password` | Token-based (link in email) | **Yes**`passwordResetValidation` requires uppercase + lowercase + digit |
The primary UI-driven path uses the **code-based** endpoint. The token-based endpoint is a legacy/alternative variant.
## Actors ## Actors
@@ -30,16 +42,16 @@ Self-service password recovery: request a 6-digit code by email, submit it with
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`. 3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
4. Backend `authController.requestPasswordReset` (`:542-574`): 4. Backend `authController.requestPasswordReset` (`:542-574`):
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**. - `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
- Generates a 6-digit code via `authService.generateVerificationCode()`. - Generates a **6-digit** code via `authService.generateVerificationCode()` (`Math.floor(100000 + Math.random() * 900000)`).
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user. - Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`. - Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome. 5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`. 6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`. 7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
8. Backend `authController.resetPasswordWithCode` (`:611-657`): 8. Backend `authController.resetPasswordWithCode` (`:611-657`):
- Validates code format `/^\d{6}$/`. - Validates code format `/^\d{6}$/` — codes of any other length will **always fail** here.
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`. - `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
- Hashes the new password with bcrypt cost 12. - Hashes the new password with bcrypt cost 12. **No password complexity validation is applied** — weak passwords such as `123456` or `aaaaaa` are accepted without error.
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions. - Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
- Saves. - Saves.
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login. 9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
@@ -59,7 +71,7 @@ sequenceDiagram
FE->>BE: POST /api/auth/request-password-reset { email } FE->>BE: POST /api/auth/request-password-reset { email }
BE->>DB: User.findOne({ email, status: "active" }) BE->>DB: User.findOne({ email, status: "active" })
alt user found alt user found
BE->>BE: code = generateVerificationCode() BE->>BE: code = generateVerificationCode() [6 digits]
BE->>DB: user.passwordResetCode = code\nexpires = +1h BE->>DB: user.passwordResetCode = code\nexpires = +1h
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code) BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
MAIL-->>U: Email with 6-digit code MAIL-->>U: Email with 6-digit code
@@ -68,8 +80,9 @@ sequenceDiagram
U->>FE: Enter code + new password U->>FE: Enter code + new password
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password } FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
BE->>BE: isValidVerificationCode(code) [/^\d{6}$/]
BE->>DB: User.findOne({ email, code, expires>now }) BE->>DB: User.findOne({ email, code, expires>now })
BE->>BE: bcrypt.hash(password, 12) BE->>BE: bcrypt.hash(password, 12) [no complexity check]
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
BE-->>FE: 200 "Password reset successfully" BE-->>FE: 200 "Password reset successfully"
FE-->>U: Redirect /auth/jwt/sign-in FE-->>U: Redirect /auth/jwt/sign-in
@@ -77,11 +90,26 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Source | | Method | Endpoint | Source | Notes |
|---|---|---| |---|---|---|---|
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | | `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` | Sends 6-digit code by email |
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | | `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` | Code-based; **no complexity validation** |
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) | | `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` | Token-based variant; enforces complexity via `passwordResetValidation` |
## Two-endpoint comparison
> [!important] Code-based vs token-based reset endpoints
>
> **`POST /api/auth/reset-password-with-code`** (primary UI path)
> - Uses a 6-digit numeric code delivered by email.
> - `isValidVerificationCode()` validates with `/^\d{6}$/`.
> - Has **no password complexity middleware**. Any string is accepted as the new password.
>
> **`POST /api/auth/reset-password`** (legacy token-based path)
> - Uses a URL token (link in email) rather than a short code.
> - Enforces password complexity via `passwordResetValidation` middleware (requires uppercase, lowercase, and a digit).
>
> The two endpoints provide inconsistent security guarantees. Users who reset via the code flow can set a weak password that would be rejected by the token flow.
## Database writes ## Database writes
@@ -110,6 +138,13 @@ sequenceDiagram
> [!warning] Plaintext code in logs > [!warning] Plaintext code in logs
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`. > Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
## Known issues summary
| Issue | Severity | Details |
|---|---|---|
| No password complexity on code-based reset | Security gap | `POST /api/auth/reset-password-with-code` has no complexity middleware; weak passwords accepted |
| Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not |
## Linked flows ## Linked flows
- [[Authentication Flow]] — user re-signs-in after reset. - [[Authentication Flow]] — user re-signs-in after reset.

View File

@@ -2,12 +2,20 @@
title: Payment Flow - DePay & Web3 title: Payment Flow - DePay & Web3
tags: [flow, payment, web3, wagmi, walletconnect, bsc] tags: [flow, payment, web3, wagmi, walletconnect, bsc]
related_models: ["[[Payment]]", "[[PurchaseRequest]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/decentralized/verify"] related_apis: ["POST /api/payment/decentralized/save", "POST /api/payment/decentralized/verify/:paymentId"]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit — 2026-05-29
> This document was reviewed against the live codebase. **12 corrections applied** — endpoint paths, missing route bugs, TypeScript type gaps, a security issue, and stats undercounting. See inline ⚠️ callouts throughout.
# Payment Flow — DePay & Web3 (Wallet-Direct) # Payment Flow — DePay & Web3 (Wallet-Direct)
Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]], the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC. > [!warning] Historical/legacy path
> This page describes the older wallet-direct payment path. The current primary checkout is [[PRD - Request Network In-House Checkout]] with Request Network metadata, derived destinations, and Transaction Safety Provider checks. Keep this page for migration and verification context only.
Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
## Actors ## Actors
@@ -16,8 +24,8 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
- **Wagmi / WalletConnect / MetaMask** — wallet stack. - **Wagmi / WalletConnect / MetaMask** — wallet stack.
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`. - **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC. - **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value). - **MongoDB** — `payments` collection, with `provider` distinguishing the legacy wallet-direct source from Request Network.
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds. - **Socket.IO** — `payment-created`, plus the funded-escrow cascade events when verification succeeds.
## Preconditions ## Preconditions
@@ -33,11 +41,24 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`. 2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request. 3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
> [!warning] ⚠️ SECURITY: SIM_ bypass has no environment guard
> `web3-provider.tsx` generates `SIM_`-prefixed transaction hashes on wallet connection failure with **no `process.env.NODE_ENV` check**. In production, if a wallet connection fails, a `SIM_` hash can be submitted to the verify endpoint and may bypass on-chain verification checks. An explicit `if (process.env.NODE_ENV === 'production') throw` guard is required before generating simulation hashes.
### Phase 2 — Create intent on backend ### Phase 2 — Create intent on backend
4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` depending on enum extension), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required. 4. Frontend POSTs `POST /api/payment/decentralized/save` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` — see TypeScript type note below), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`. **Auth:** Bearer JWT required.
> [!warning] ⚠️ TypeScript type gap — `PaymentProvider`
> The frontend `PaymentProvider` type is defined as `'request.network' | 'test' | 'other'`. The values **`'shkeeper'`** and **`'decentralized'`** are missing from the union. Any UI provider-switch logic that branches on `provider` will fall through to an unknown/default state for these two providers. Add both to the type definition.
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`). 5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
> [!warning] ⚠️ NOT IMPLEMENTED — `createDePayIntent()`
> The frontend action `createDePayIntent()` POSTs to `/payment/depay/intents`, which **does not exist** on the backend. Calling this action will always return 404. The working intent endpoint is `POST /api/payment/decentralized/save` (step 4 above). Do not use `createDePayIntent()` until a `/payment/depay/intents` route is added to the backend.
> [!warning] ⚠️ KNOWN BUG — `getProviderIntentEndpoint()` routing
> The `getProviderIntentEndpoint()` factory function **always** resolves to `/payment/request-network/intents` regardless of the `provider` argument passed in. Any SHKeeper checkout that calls this helper will POST to the wrong (Request Network) intent endpoint. This function requires a proper `switch`/`if` on `provider` before it can be used for non-Request-Network flows.
### Phase 3 — Token approval (ERC-20 / BEP-20) ### Phase 3 — Token approval (ERC-20 / BEP-20)
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract. 6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
@@ -52,7 +73,7 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
### Phase 5 — Backend verification ### Phase 5 — Backend verification
11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with `{ transactionHash }`. **Auth:** Bearer JWT required (owner or admin). 11. Frontend POSTs `POST /api/payment/decentralized/verify/:paymentId` with body `{ transactionHash }`. The `paymentId` is a **path parameter**. **Auth:** Bearer JWT required (owner or admin).
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`): 12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`. - JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
- Confirms `receipt.status === '0x1'` (success). - Confirms `receipt.status === '0x1'` (success).
@@ -60,13 +81,21 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
- Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment. - Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment.
13. On success the backend: 13. On success the backend:
- Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`. - Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`.
- Triggers the **same cascade** as the SHKeeper webhook: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events. - Triggers the **same funded-escrow cascade**: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`. 14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
> [!warning] ⚠️ Stats undercounting — `'completed'` not counted as successful
> The admin stats aggregate counts only payments with `status === 'confirmed'` as successful. DePay and SHKeeper payments reach **`'completed'`** as their terminal state (not `'confirmed'`), so the admin success count will be **artificially low**. The aggregate must include both `'confirmed'` and `'completed'` in the success set.
### Phase 6 — Frontend reaction ### Phase 6 — Frontend reaction
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state. 15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
> [!warning] ⚠️ Non-existent status/confirm endpoints — dispute payment card
> The **dispute payment card** "Verify" button calls `getPaymentStatus()`, which internally hits `GET /payment/:id/status`. This route **does not exist** — there is no `/status` sub-route on any payment document endpoint. The call always returns 404. Similarly, `POST /payment/:id/confirm` **does not exist**; no `/confirm` sub-route is registered. Remove both from any frontend code paths and rely on socket events (`payment-update`, `payment-completed`) or the verify endpoint instead.
>
> Additionally, `cancelPayment()` in the web3 context is a **local UI state reset only** — it does **not** make an HTTP call. `DELETE /payment/:id` does not exist; there is no DELETE handler on any payment route.
## Sequence diagram ## Sequence diagram
```mermaid ```mermaid
@@ -86,7 +115,7 @@ sequenceDiagram
opt chainId != 56 opt chainId != 56
FE->>W: wallet_switchEthereumChain(0x38) FE->>W: wallet_switchEthereumChain(0x38)
end end
FE->>BE: POST /api/payment/decentralized/create FE->>BE: POST /api/payment/decentralized/save
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW}) BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
BE-->>FE: { paymentId, escrowAddress, amount } BE-->>FE: { paymentId, escrowAddress, amount }
opt allowance < amount opt allowance < amount
@@ -97,7 +126,7 @@ sequenceDiagram
W-->>FE: tx broadcast W-->>FE: tx broadcast
W-->>BC: signed tx W-->>BC: signed tx
BC-->>W: tx confirmed BC-->>W: tx confirmed
FE->>BE: POST /api/payment/decentralized/verify { paymentId, txHash } FE->>BE: POST /api/payment/decentralized/verify/:paymentId { txHash }
BE->>BC: eth_getTransactionReceipt(txHash) BE->>BC: eth_getTransactionReceipt(txHash)
BC-->>BE: { status:0x1, blockNumber, logs } BC-->>BE: { status:0x1, blockNumber, logs }
BE->>BC: eth_blockNumber BE->>BC: eth_blockNumber
@@ -112,16 +141,57 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Source | | Method | Endpoint | Notes | Source |
|---|---|---| |---|---|---|---|
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` | | `POST` | `/api/payment/decentralized/save` | Create intent | `decentralizedPaymentRoutes.ts` |
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` | | `POST` | `/api/payment/decentralized/verify/:paymentId` | `paymentId` is a **path param** | `decentralizedPaymentRoutes.ts` |
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) | | `POST` | `/api/payment/payments/:id/fetch-tx` | Manual tx rechecker — **NO AUTH** (exploitable without credentials) | `paymentRoutes.ts` |
| ~~`POST /api/payment/decentralized/create`~~ | | ⚠️ **404 — does not exist.** Use `/save` instead. | — |
| ~~`GET /payment/:id/status`~~ | | ⚠️ **404 — does not exist.** No `/status` sub-route. | — |
| ~~`POST /payment/:id/confirm`~~ | | ⚠️ **404 — does not exist.** No `/confirm` sub-route. | — |
| ~~`DELETE /payment/:id`~~ | | ⚠️ **404 — does not exist.** `cancelPayment()` is UI-only. | — |
| ~~`POST /payment/depay/intents`~~ | | ⚠️ **NOT IMPLEMENTED**`createDePayIntent()` target. | — |
> [!warning] ⚠️ `/api/payment/payments/:id/fetch-tx` has no authentication
> The endpoint `POST /api/payment/payments/:id/fetch-tx` (note the `/payments/` infix — the previously documented path `/api/payment/fetch-tx/:paymentId` was wrong on both method and path) accepts requests **without any authentication check**. Any unauthenticated caller can trigger a blockchain re-fetch for any payment ID. This must be gated behind at minimum an admin JWT before production use.
### Request Network sub-routes — NOT IMPLEMENTED
The following four Request Network payout/release/refund sub-paths are **not registered** in the backend router. All return 404:
| Path | Status |
|---|---|
| `POST /api/payment/request-network/:id/payout/initiate` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/payout/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/release/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
| `POST /api/payment/request-network/:id/refund/confirm` | ⚠️ NOT IMPLEMENTED — 404 |
## Database writes ## Database writes
- **`payments`** — same model as the SHKeeper flow. `provider` distinguishes the source. - **`payments`** — same model as the Request Network flow. `provider` distinguishes the source.
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical cascade to [[Payment Flow - SHKeeper]] (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out). - **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical funded-escrow cascade (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
### Payment status values
| `status` | `escrowState` | Meaning |
|---|---|---|
| `pending` | — | Intent created, awaiting on-chain transfer |
| `completed` | `funded` | On-chain transfer verified (terminal success for DePay/wallet-direct) |
| `failed` | — | Transaction reverted or verification failed |
### escrowState values (backend-authoritative)
| `escrowState` | Meaning |
|---|---|
| `funded` | Escrow received the on-chain transfer |
| `releasable` | Escrow funds cleared for release to seller |
| `releasing` | Release to seller in progress (intermediate state) |
| `released` | Funds sent to seller |
| `refunding` | Refund to buyer in progress |
| `refunded` | Funds returned to buyer |
> [!note] `'completed'` is not counted as a successful payment in stats
> `paymentService.getPaymentStats` counts only `status === 'confirmed'` as `successfulPayments`. DePay/wallet-direct payments terminate at `'completed'`, so they are **excluded** from the success count. The aggregate must include `'completed'` alongside `'confirmed'` to avoid undercounting.
## Socket events emitted ## Socket events emitted
@@ -132,7 +202,7 @@ sequenceDiagram
## Side effects ## Side effects
- **No SHKeeper involvement** — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via [[Payout Flow]] (SHKeeper payouts API) or manual admin signing using `admin-wallet-payout.tsx` UI. - **No provider custody** — the escrow wallet is custodial; the platform admin/custody signer controls the keys. Releases from this wallet to sellers should follow [[Payout Flow]] and the Safe/hardware-backed roadmap in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
- **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed. - **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed.
## Error / edge cases ## Error / edge cases
@@ -144,7 +214,7 @@ sequenceDiagram
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status. - **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended. - **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production. - **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`/api/payment/fetch-tx/:paymentId`) or admin tool can replay verification from the txHash. - **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`POST /api/payment/payments/:id/fetch-tx`) or admin tool can replay verification from the txHash.
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC. - **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
> [!warning] Verify the event log, not just the receipt > [!warning] Verify the event log, not just the receipt
@@ -152,7 +222,8 @@ sequenceDiagram
## Linked flows ## Linked flows
- [[Payment Flow - SHKeeper]] — sibling pay-in path; same downstream cascade. - [[PRD - Request Network In-House Checkout]] — current primary checkout.
- [[Payment Flow - SHKeeper]] — historical sibling pay-in path retained for migration context.
- [[Escrow Flow]] — funded state semantics. - [[Escrow Flow]] — funded state semantics.
- [[Payout Flow]] — releasing the funded escrow to the seller. - [[Payout Flow]] — releasing the funded escrow to the seller.
- [[Dispute Flow]] — refunds back to the buyer's verified wallet. - [[Dispute Flow]] — refunds back to the buyer's verified wallet.

View File

@@ -2,11 +2,19 @@
title: Payment Flow - SHKeeper title: Payment Flow - SHKeeper
tags: [flow, payment, shkeeper, crypto, escrow, webhook] tags: [flow, payment, shkeeper, crypto, escrow, webhook]
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"] related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "GET /api/payment/shkeeper/status/:id"] related_apis: ["POST /api/payment/shkeeper/intents", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit — 2026-05-29
> This document was reviewed against the live codebase. **3 corrections applied**: (1) the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), (2) the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment, and (3) the intent-creation endpoint corrected from `/shkeeper/create` to `/shkeeper/intents` and parallel stats/export paths documented.
# Payment Flow — SHKeeper (Crypto Pay-In) # Payment Flow — SHKeeper (Crypto Pay-In)
> [!warning] Historical migration document
> This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is [[PRD - Request Network In-House Checkout]], and the current escrow/custody model is [[Escrow Flow]] plus [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded. End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
## Supported assets ## Supported assets
@@ -29,7 +37,7 @@ Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperSer
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm). - **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`. - **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL). - **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
- **Socket.IO** — `payment-created`, `seller-offer-update`, `purchase-request-update`. - **Socket.IO** — `payment-created`, `payment-update`, `template-checkout-payment-confirmed`, `seller-offer-update`, `purchase-request-update`.
## Preconditions ## Preconditions
@@ -60,7 +68,7 @@ stateDiagram-v2
### Phase 1 — Create intent ### Phase 1 — Create intent
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay). 1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
2. Frontend POSTs `POST /api/payment/shkeeper/create` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`. 2. Frontend POSTs `POST /api/payment/shkeeper/intents` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
3. Backend `createPayInIntent`: 3. Backend `createPayInIntent`:
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`). - Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation. - **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
@@ -119,7 +127,11 @@ stateDiagram-v2
### Phase 4 — Frontend reaction ### Phase 4 — Frontend reaction
21. The buyer's checkout page subscribes to socket events and polls `GET /api/payment/shkeeper/status/{paymentId}`. When status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery). 21. The buyer's checkout page subscribes to socket events (`payment-update`, `template-checkout-payment-confirmed`). When the status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
> [!warning] No HTTP polling endpoint — socket events only
> `GET /api/payment/shkeeper/status/:paymentId` **does not exist** — there is no polling route in `shkeeperRoutes.ts`. Status transitions must be observed via Socket.IO events (`payment-update`, `template-checkout-payment-confirmed`). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner. 22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
## Sequence diagram ## Sequence diagram
@@ -138,7 +150,7 @@ sequenceDiagram
actor S as Seller actor S as Seller
B->>FE: Choose offer, click "Pay" B->>FE: Choose offer, click "Pay"
FE->>BE: POST /api/payment/shkeeper/create FE->>BE: POST /api/payment/shkeeper/intents
BE->>DB: dedupe / upsert Payment(status:"pending") BE->>DB: dedupe / upsert Payment(status:"pending")
BE->>R: getCachedWallet(amount, token, network, requestId) BE->>R: getCachedWallet(amount, token, network, requestId)
alt cache hit alt cache hit
@@ -166,19 +178,40 @@ sequenceDiagram
BE->>IO: emit seller-{winner} 'payment-completed' BE->>IO: emit seller-{winner} 'payment-completed'
BE->>IO: emit seller-{loser_i} 'offer-rejected' BE->>IO: emit seller-{loser_i} 'offer-rejected'
BE-->>SK: 202 OK BE-->>SK: 202 OK
IO-->>FE: status updated IO-->>FE: payment-update / status updated
IO-->>S: dashboard updates IO-->>S: dashboard updates
FE-->>B: "Payment received ✓" FE-->>B: "Payment received ✓"
``` ```
## API calls ## API calls
| Method | Endpoint | Purpose | Source | | Method | Endpoint | Purpose | Auth | Source |
|---|---|---|---| |---|---|---|---|---|
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` | | `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` | | `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` | | `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `paymentRoutes.ts` | | `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund` | Refund to buyer | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | Bearer JWT | `paymentRoutes.ts` |
| `GET` | `/api/payment/payments/stats` | Payment statistics (admin-gated strict) | Bearer JWT + admin role | `paymentRoutes.ts` |
| `GET` | `/api/payment/stats` | Payment statistics (no admin guard) | Bearer JWT | `paymentRoutes.ts` |
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | **404 — does not exist.** Use socket events instead. | — | — |
> [!note] Two parallel stats paths
> Two separate stats endpoints exist with different auth levels:
> - `GET /api/payment/payments/stats` — admin-gated (strict role check); intended for admin dashboard.
> - `GET /api/payment/stats` — authenticated but no admin guard; accessible to any logged-in user.
> Similarly, export endpoints exist at two paths with different auth levels. Confirm which is appropriate for each consumer before wiring the frontend.
> [!warning] Release/refund path correction
> Previously documented paths included a `/shkeeper/` segment that does **not** exist in the router:
> - ~~`POST /api/payment/shkeeper/:id/release`~~ → correct: `POST /api/payment/:id/release`
> - ~~`POST /api/payment/shkeeper/:id/release/confirm`~~ → correct: `POST /api/payment/:id/release/confirm`
> - ~~`POST /api/payment/shkeeper/:id/refund`~~ → correct: `POST /api/payment/:id/refund`
> - ~~`POST /api/payment/shkeeper/:id/refund/confirm`~~ → correct: `POST /api/payment/:id/refund/confirm`
>
> The `/shkeeper/` infix never existed on release/refund routes. These are generic payment lifecycle endpoints shared across all providers.
## Database writes ## Database writes
@@ -192,6 +225,8 @@ sequenceDiagram
## Socket events emitted ## Socket events emitted
- **`payment-created`** (global) — broadcast on intent creation. - **`payment-created`** (global) — broadcast on intent creation.
- **`payment-update`** — status change notifications to the buyer's checkout page.
- **`template-checkout-payment-confirmed`** — for template checkout flows.
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller. - **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller. - **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`. - **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.

View File

@@ -0,0 +1,179 @@
---
title: Payment Flow - Scanner (In-House)
tags: [flow, scanner, payment]
created: 2026-05-30
---
# Payment Flow — AMN Pay Scanner (In-House)
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
---
## 1. High-level sequence
```
Buyer Backend Scanner Chain
│ │ │ │
│ initiate payment │ │ │
│────────────────────►│ │ │
│ │ POST /intents │ │
│ │───────────────────►│ │
│ │ 200 checkoutBlock │ │
│ │◄───────────────────│ │
│ checkoutBlock │ │ │
│◄────────────────────│ │ │
│ │ │ │
│ sign + submit tx ──────────────────────────────────────►│
│ │ │ (polling) │
│ │ │◄────────────────│
│ │ │ log matched │
│ │ │ confirmations… │
│ │◄───────────────────│ │
│ │ POST callbackUrl │ │
│ │ (webhook) │ │
│ │ │ │
│ payment confirmed │ │ │
│◄────────────────────│ │ │
```
---
## 2. Step-by-step
### Step 1 — Backend creates an intent
When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:
```
POST http://scanner:8080/intents
Authorization: Bearer <SCANNER_API_KEY>
{
"intentId": "<payment._id>",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xSellerWalletAddress",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
"confirmations": 12
}
```
The scanner responds with a `checkoutBlock` that the backend passes to the frontend.
### Step 2 — Frontend shows checkout
The `checkoutBlock` contains everything the frontend needs to build the `ERC20FeeProxy.transferWithReferenceAndFee` calldata:
| Field | Used for |
|---|---|
| `proxyAddress` | contract to call |
| `tokenAddress` | ERC20 token |
| `destination` | `_to` param |
| `paymentReference` | `_paymentReference` param (8-byte reference) |
| `amountWei` | `_amount` param |
| `feeAmount` | `_feeAmount` param (always `"0"` currently) |
| `feeAddress` | `_feeAddress` param (always dead address) |
For Tron/TON the buyer sends a plain TRC20/Jetton transfer to `destination`; there is no proxy contract.
### Step 3 — Buyer submits transaction
The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.
### Step 4 — Scanner detects and confirms
**EVM path:**
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
2. `validateLogMatchesIntent` verifies token address, destination, and amount
3. Intent moves to `confirming`; scanner waits for N blocks
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`
**Tron path:**
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)
**TON path:**
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed`
### Step 5 — Webhook delivery
The scanner POSTs to `callbackUrl` with:
```json
{
"intentId": "...",
"paymentReference": "0x...",
"txHash": "0x...",
"blockNumber": 39000010,
"amount": "10000000000000000000",
"token": "0x55d...",
"chainId": 56,
"status": "confirmed"
}
```
Header `X-AMN-Signature` = `HMAC-SHA256(body, callbackSecret)`.
The backend verifies the signature, matches the intentId to a Payment record, and marks it paid.
### Step 6 — Backend acknowledges
Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the intent lifecycle ends.
---
## 3. Failure paths
### Webhook delivery failure
If the backend returns non-2xx or is unreachable, the scanner retries:
```
attempt 1: after 5 s
attempt 2: after 30 s
attempt 3: after 2 min
attempt 4: after 10 min
attempt 5: after 1 h
→ status = webhook_failed
```
`webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
On startup the scanner reconciles any `confirmed` intents with `webhook_delivered_at IS NULL` (crash recovery).
### Intent expiry
Intents in `pending` or `confirming` status older than `INTENT_TTL_HOURS` (default 24 h) are moved to `expired` by a background ticker running every hour.
`confirming` intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.
### Amount underpayment
Transfers where the on-chain amount is less than `intent.Amount` are silently skipped. The intent remains `pending` until the TTL.
### Wrong token or destination
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
---
## 4. Key differences from Request Network integration
| Dimension | Request Network | AMN Pay Scanner |
|---|---|---|
| Dependency | RN SDK + API | None (direct RPC) |
| Payment reference | RN-generated | Internal HMAC derivation |
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
| Tron | Not supported | TRC20 Transfer events via TronGrid |
| TON | Not supported | Jetton transfers via TonCenter v3 |
| Confirmations | RN handled | Per-chain configurable |
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
| State store | External (RN cloud) | Internal SQLite |

View File

@@ -1,133 +1,190 @@
--- ---
title: Payout Flow title: Payout Flow
tags: [flow, payment, payout, shkeeper, seller] tags: [flow, payment, payout, release, refund, custody]
related_models: ["[[Payment]]"] related_models: ["[[Payment]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"] related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund", "POST /api/payment/:id/refund/confirm"]
--- ---
# Payout Flow # Payout Flow
How the **seller receives the escrowed crypto** once the order is complete. Two variants are implemented: > **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))
1. **SHKeeper Payouts API** (`shkeeperPayoutService.ts`) — the gateway signs and broadcasts on behalf of the platform. This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
2. **Manual admin wallet payout** (`admin-wallet-payout.tsx`) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
Both result in `Payment.escrowState = 'released'` and an outgoing `Payment` record with `direction: 'out'`. The current flow is no longer SHKeeper payout-task centric. Release and refund are instruction-based:
1. Backend validates policy, dispute hold, and ledger availability.
2. Backend builds a release/refund instruction.
3. A custody signer executes the on-chain transaction.
4. Backend confirms the tx hash and appends the ledger entry.
Today the custody signer can be an admin/Trezor path when enabled. The roadmap target is Safe multisig execution before any custom escrow contract pilot. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## Actors ## Actors
- **Admin** (or scheduled system trigger) — initiates the payout. - **Admin / mediator** -- initiates release/refund after delivery confirmation or dispute resolution.
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`. - **Custody signer** -- Trezor proof today when enabled; target state is Safe multisig owners.
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes. - **Seller** -- recipient for release.
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs). - **Buyer** -- recipient for refund.
- **Blockchain (BSC)** — final on-chain settlement. - **Backend** -- `releaseRefundService.ts`, payment adapter, ledger service, Trezor service.
- **MongoDB** — separate `Payment` document with `direction: 'out'`. - **Blockchain** -- final on-chain settlement.
- **MongoDB** -- `Payment` and `FundsLedgerEntry`.
## Preconditions ## Preconditions
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`). - The pay-in `Payment` is funded or releasable.
- The seller has set `profile.walletAddress` (validated `^0x...` format). - The release/refund amount is positive and does not exceed available ledger balance.
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`). - No active dispute hold blocks the operation, unless the operation is the explicit dispute resolution path.
- Recipient wallet is known and verified.
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step **must** include the expected Trezor operation signature (see gate below).
- Production target: Safe multisig execution is required for custody movement.
## Step-by-step narrative ## Release Narrative
### SHKeeper-mediated payout 1. Buyer confirms delivery, an auto-release policy matures, or a dispute resolves for the seller.
2. Admin calls `POST /api/payment/:id/release` with optional partial amount.
3. Backend loads the `Payment`, validates ledger availability when `PAYMENT_LEDGER_ENFORCEMENT=true`, and returns an instruction payload.
4. Custody signer broadcasts the seller payment transaction.
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
6. Backend verifies signer proof when required, confirms adapter state, appends a `release` ledger entry, and marks escrow released.
1. Admin (or the auto-release scheduler — not yet implemented) hits `POST /api/payment/shkeeper/payout` with `{ purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }`. ## Refund Narrative
2. Backend `shkeeperPayoutService.createPayoutTask` (`shkeeperPayoutService.ts:40-150`):
- Validates ObjectIds and the `recipientAddress` (`startsWith('0x')`).
- **Idempotency**: `Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } })` — if found, reuses it.
- Creates a new `Payment` document with `direction: 'out'`, `escrowState: 'releasing'`, `blockchain.receiver = recipientAddress`.
- Calls SHKeeper Payouts API (`POST /api/v1/payout`) with the body documented at <https://shkeeper.io/api/#tag/Payouts>. SHKeeper returns a `task_id`.
- Stores `Payment.providerPaymentId = task_id`, `metadata.shkeeperTaskId = task_id`, `metadata.payoutType = 'seller-payment'`.
3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls `GET /api/v1/payout/{task_id}`) and the system flips `Payment.status = 'completed'`, `escrowState = 'released'`, populates `blockchain.transactionHash`.
4. The original pay-in `Payment` is updated in tandem: `escrowState = 'released'`, `PurchaseRequest.status = 'seller_paid'``completed`.
5. Notifications: `notifyPayoutSent` to the seller, internal admin log.
### Manual admin payout 1. Dispute resolves for the buyer, order is cancelled before fulfillment, or support executes an approved recovery.
2. Admin calls `POST /api/payment/:id/refund`.
3. Backend validates available funds and policy.
4. Custody signer broadcasts the refund transaction.
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
6. Backend appends a `refund` ledger entry and marks escrow refunded.
1. Admin opens the request detail in the admin view; the admin-step component `admin-wallet-payout.tsx` shows the recipient and amount. ## Sequence Diagram
2. Admin connects their wallet (`useWeb3` / `web3Service.connect()`).
3. Admin clicks "Send payout"; wagmi triggers `transfer(recipient, amount)` on the USDT contract.
4. After confirmation, the admin clicks "Confirm in system", which POSTs `POST /api/payment/admin/confirm-tx/:paymentId` with `{ txHash, kind: 'release' }`.
5. Backend `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets `status: 'completed'`, `escrowState: 'released'`, `blockchain.transactionHash = txHash`.
### Sequence diagram (SHKeeper payout)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor A as Admin/System actor A as Admin
actor C as Custody signer
participant BE as Backend participant BE as Backend
participant DB as MongoDB participant DB as MongoDB
participant SK as SHKeeper Payout API participant BC as EVM Chain
participant BC as BSC actor R as Recipient
actor S as Seller
A->>BE: POST /api/payment/shkeeper/payout A->>BE: POST /api/payment/{id}/release or refund
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"}) BE->>DB: Load Payment + FundsLedger balance
BE->>SK: POST /api/v1/payout {to, amount, crypto} BE->>BE: Check dispute hold + ledger availability
SK-->>BE: { task_id, status:"pending" } BE-->>A: unsigned release/refund instruction
BE->>DB: Payment.providerPaymentId=task_id A->>C: Request Trezor/Safe execution
SK->>BC: signed payout tx (managed wallet) C->>BC: Broadcast transfer
BC-->>SK: confirmed BC-->>C: txHash
SK->>BE: webhook payout-completed (or BE polls) A->>BE: POST /confirm { txHash, trezor proof if safekeeping }
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash BE->>BE: Verify proof if required
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid" BE->>DB: append release/refund ledger entry
BE->>S: notifyPayoutSent BE->>DB: update Payment escrowState
BE-->>R: notification (no realtime socket listener — see gap below)
``` ```
## API calls ## API Calls
| Method | Endpoint | Source | ### Release / Refund (custody) — correct paths
These are mounted on `paymentControllerRouter` at `/api/payment` (`backend/src/services/payment/paymentControllerRoutes.ts:23-26`). Note: **no `/shkeeper/` segment**.
| Method | Endpoint | Purpose |
|---|---|---| |---|---|---|
| `POST` | `/api/payment/shkeeper/payout` | `shkeeperPayoutRoutes.ts``createPayoutTask` | | `POST` | `/api/payment/:id/release` | Build release instruction |
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status | | `POST` | `/api/payment/:id/release/confirm` | Confirm release transaction |
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation | | `POST` | `/api/payment/:id/refund` | Build refund instruction |
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) | | `POST` | `/api/payment/:id/refund/confirm` | Confirm refund transaction |
| `GET` | `/api/admin/payments/awaiting-confirmation` | Admin view of payments blocked on confirmation depth |
| `GET` | `/api/payment/derived-destinations` | Admin view of derived destination sweep state |
## Database writes ### Request Network — actually implemented routes
- **`payments`** — new outgoing document; updates to `status`, `escrowState`, `blockchain.transactionHash` as the task progresses. Mounted at `/api/payment/request-network` (`app.ts:428``requestNetwork/requestNetworkRoutes.ts`). Only these exist:
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
- **`purchaserequests`** — `status` advances to `seller_paid``completed`. | Method | Endpoint | Purpose |
- **`notifications`** — seller payout receipt. |---|---|---|
| `POST` | `/api/payment/request-network/pay-in` | Create a pay-in intent (authenticated) — `requestNetworkRoutes.ts:111` |
| `POST` | `/api/payment/request-network/intents` | Create checkout intent — `requestNetworkRoutes.ts:289` |
| `GET` | `/api/payment/request-network/:paymentId/checkout` | In-house checkout block fetcher — `requestNetworkRoutes.ts:152` |
| `POST` | `/api/payment/request-network/webhook` | Provider webhook (raw body) — `requestNetworkRoutes.ts:330` |
> [!warning] ⚠️ NOT IMPLEMENTED — Request Network payout/release/refund sub-routes
> The following routes are **not registered anywhere** and return **404**:
> - `POST /api/payment/request-network/:id/payout/initiate`
> - `POST /api/payment/request-network/:id/payout/confirm`
> - `POST /api/payment/request-network/:id/release/confirm`
> - `POST /api/payment/request-network/:id/refund/confirm`
>
> Release and refund are handled exclusively by the custody routes under `/api/payment/:id/...` listed above — **not** under the `request-network` namespace.
## Custody-signer / Trezor safekeeping gate
> [!warning] Safekeeping gate blocks the legacy non-custodial helpers
> When `TREZOR_SAFEKEEPING_REQUIRED=true` (`backend/src/services/trezor/trezorService.ts:214`), the release/refund `confirm` endpoints require a Trezor operation signature in the request body.
>
> - The **active admin UI** path uses `TrezorSignDialog` (`frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`), wired into the awaiting-confirmation list view. It builds the signed payload via `getTrezorOperationMessage` + `trezorSignMessage` and posts `{ txHash, amount, trezor: { message, signature } }` through `confirmRelease` / `confirmRefund` (`frontend/src/actions/trezor.ts:108,133`). This path satisfies the gate.
> - The **legacy helpers** `confirmReleaseTx` / `confirmRefundTx` (`frontend/src/actions/payment.ts:487,503`) post only `{ txHash, ...extra }` — by default **no Trezor proof**. They have **no UI callers** today, but if used with safekeeping enabled the backend will **reject** the payout. Prefer the `TrezorSignDialog` flow; remove or retrofit the legacy helpers to attach the signature.
## Derived-destinations sweep
HD-wallet derived-destination sweep infrastructure exists but is **admin-tooling only**:
- Routes: `GET /api/payment/derived-destinations` (`app.ts:546``wallets/derivedDestinationRoutes`).
- Cron: `startSweepCron()` auto-starts only when `DERIVED_DESTINATION_SWEEP_AUTOSTART=true` (`app.ts:578-582`, `wallets/sweepService.ts`).
- Model: `DerivedDestination` with statuses `active`/`swept`/`sweeping`/`quarantined` (`models/DerivedDestination.ts:35`).
This is not part of the buyer/seller payout UX; it consolidates funds from per-payment derived addresses.
## Database Writes
- **`payments`** -- status, `escrowState`, `blockchain.transactionHash`, signer metadata.
- **`funds_ledger_entries`** -- append-only `release` or `refund` entry with idempotency key.
- **`purchaserequests`** -- terminal business state after release/refund completes.
- **`notifications`** -- release/refund receipt to the relevant party.
## Socket events emitted ## Socket events emitted
- **`payment-status`** (admin) on each transition. > [!warning] Real-time payout/payment events have NO frontend listeners
- **`purchase-request-update`** `status-changed`. > Two seller-facing socket events are emitted by the backend but **no frontend code subscribes to them**, so sellers receive no real-time notification:
> - **`payout-completed`** → `user-{sellerId}`, emitted after admin wallet payout (`backend/src/services/payment/decentralizedPaymentService.ts:911`). No frontend listener.
> - **`payment-received`** → `user-{sellerId}`, emitted on Web3 verify (`backend/src/services/payment/paymentRoutes.ts:622`) and from `marketplace/routes.ts:2611`. No frontend listener.
>
> Until the frontend socket layer registers handlers for these, sellers must refresh / poll to see payout and incoming-payment state. Persisted DB notifications still surface through the standard notification channel.
## Side effects ## Error / Edge Cases
- **`fix-transaction-hashes.js`** at repo root (`backend/fix-transaction-hashes.js`) — script used to backfill missing `blockchain.transactionHash` on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash. - **Insufficient ledger balance** -- reject instruction build/confirm.
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy. - **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
- **Missing signer proof** -- reject confirm when `TREZOR_SAFEKEEPING_REQUIRED=true` (legacy `confirmReleaseTx`/`confirmRefundTx` helpers omit it — see gate above).
- **Custody tx sent but not confirmed in app** -- reconcile by tx hash and append the missing ledger entry once verified.
- **Partial split** -- build separate release and refund instructions whose sum does not exceed available balance.
- **Payout reverted** -- leave escrow in failed/retryable state and do not append the terminal ledger entry.
- **Wrong namespace** -- calling release/refund under `/api/payment/request-network/:id/...` returns 404 (those routes do not exist).
## Error / edge cases ## Legacy SHKeeper Note
- **Invalid recipient address** → throws synchronously, no DB record created. Older versions used SHKeeper payout tasks and scripts such as `fix-transaction-hashes.js`. Those references remain useful for historical reconciliation, but new release/refund work should use the instruction, ledger, and custody-signer flow described here.
- **SHKeeper insufficient hot-wallet balance** → SHKeeper returns an error; payout task stays `pending`, backend logs.
- **Duplicate payout request** → idempotency: existing payment returned with no extra SHKeeper call.
- **Payout reverted on chain** → SHKeeper marks the task `failed`; backend sets `Payment.status = 'failed'`, `escrowState = 'failed'`. Admin retries.
- **Missing `transactionHash` after success** → use `fix-transaction-hashes.js` to backfill.
- **Manual payout signed but never confirmed in system** → on-chain transfer happened, but `Payment.escrowState` stays `releasing`. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
- **Seller changes wallet address mid-flight** → the saved `recipientAddress` is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
> [!warning] Auto-release is not yet implemented ## Linked Flows
> Today, payouts are admin-initiated. The flow is ready for an automatic trigger when [[Delivery Confirmation Flow]] completes — implement a cron job or queue worker that scans for `PurchaseRequest.status='delivered'` and auto-creates payouts after a configurable grace period.
## Linked flows - [[Escrow Flow]] -- sets up the conditions under which release/refund is allowed.
- [[Delivery Confirmation Flow]] -- happy-path release trigger.
- [[Dispute Flow]] -- can divert release to refund or split.
- [[Trezor Safekeeping Flow]] -- hardware-backed operation approval.
- [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- Safe-first custody roadmap.
- [[Escrow Flow]] — sets up the conditions under which payout is allowed. ## Source Files
- [[Delivery Confirmation Flow]] — green-lights the payout.
- [[Dispute Flow]] — can divert funds to a refund instead.
- [[Notification Flow]] — payout receipt to seller.
## Source files - Backend: `backend/src/services/payment/paymentControllerRoutes.ts:23-26` (release/refund routes)
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:111,152,289,330` (implemented RN routes)
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutService.ts` - Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts` - Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload) - Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script) - Backend: `backend/src/services/trezor/trezorService.ts:214` (safekeeping gate)
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx` - Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Frontend: `frontend/src/web3/web3Service.ts` - Backend: `backend/src/services/payment/decentralizedPaymentService.ts:911` (`payout-completed` emit)
- Backend: `backend/src/services/payment/paymentRoutes.ts:622` (`payment-received` emit)
- Backend: `backend/src/services/payment/wallets/sweepService.ts`, `models/DerivedDestination.ts` (sweep infra)
- Frontend: `frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`, `frontend/src/actions/trezor.ts:108,133` (active Trezor confirm path)
- Frontend: `frontend/src/actions/payment.ts:487,503` (legacy `confirmReleaseTx`/`confirmRefundTx`, no Trezor proof)

View File

@@ -5,6 +5,11 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"] related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
--- ---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.
# Purchase Request Flow # Purchase Request Flow
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]]. A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
@@ -31,36 +36,40 @@ Status progression is enforced by `STATUS_PROGRESSION_ORDER` in `PurchaseRequest
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createPurchaseRequest() [*] --> pending: createPurchaseRequest()
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer pending --> active: request activated
pending --> pending_payment: payment initiated
pending_payment --> active: payment confirmed
active --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]]) received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
in_negotiation --> received_offers: counter rejected in_negotiation --> received_offers: counter rejected
received_offers --> payment: SHKeeper webhook PAID\n(selected offer) received_offers --> payment: Request Network payment confirmed\n(selected offer)
in_negotiation --> payment: same in_negotiation --> payment: same
payment --> processing: seller acknowledges payment --> processing: seller acknowledges
processing --> delivery: seller marks shipped processing --> delivery: seller marks shipped
delivery --> delivered: buyer enters delivery code delivery --> delivered: buyer enters delivery code
delivered --> confirming: optional auto-release timer delivered --> confirming: optional auto-release timer
confirming --> completed: escrow released to seller confirming --> completed: escrow released to seller
completed --> finalized: ratings exchanged
finalized --> archived: 30 days idle
pending --> cancelled: buyer cancels (any pre-payment status) pending --> cancelled: buyer cancels (any pre-payment status)
active --> cancelled
received_offers --> cancelled received_offers --> cancelled
in_negotiation --> cancelled in_negotiation --> cancelled
cancelled --> [*] cancelled --> [*]
archived --> [*] completed --> [*]
``` ```
Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseRequestService.ts:28`). Terminal statuses: `completed`, `cancelled` (`PurchaseRequestService.ts:28`).
> [!note] Statuses `finalized` and `archived` do NOT exist in the frontend `IPurchaseRequest` type and are not live statuses. They are not part of the active state machine.
## Step-by-step narrative ## Step-by-step narrative
### Multi-step wizard ### Multi-step wizard
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`. 1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5200 chars), description (202000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`). 2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5200 chars), description (52000 chars, **minimum is 5 characters** per frontend Zod schema — not 20), category selection (dropdown populated from `GET /api/marketplace/categories`).
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`). 3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (low/medium/high), preferred sellers (typeahead bound to `GET /api/users/sellers`; `"all"` means public). 4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (`low | medium | high | urgent`), preferred sellers (typeahead bound to `GET /api/marketplace/sellers`; `"all"` means public).
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/files/upload` — returns URLs persisted into `attachments[]`. 5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/marketplace/purchase-requests/:id/attachments` — returns URLs persisted into `attachments[]`.
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers. 6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
### Submission ### Submission
@@ -73,9 +82,9 @@ Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseR
- Builds and saves the `PurchaseRequest` document with `status: "pending"`. - Builds and saves the `PurchaseRequest` document with `status: "pending"`.
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown. 9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`): 10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
- If `isPublic`: `User.find({ role: "seller", status: "active" })`. - If `isPublic`: emits `new-purchase-request` to the shared **`sellers` room** (all connected sellers receive it in a single emit — no per-seller iteration for the socket event itself).
- Otherwise: only the curated `preferredSellerIds`. - For per-seller in-app notifications (bell icon): `User.find({ role: "seller", status: "active" })` OR only the curated `preferredSellerIds`.
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO. - Iterates with **50 ms stagger** between notification writes to avoid overwhelming Mongo.
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`). - For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]]. 11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
@@ -112,7 +121,7 @@ sequenceDiagram
BE-->>FE: { description } BE-->>FE: { description }
end end
opt attachments opt attachments
FE->>BE: POST /api/files/upload FE->>BE: POST /api/marketplace/purchase-requests/:id/attachments
BE-->>FE: { url } BE-->>FE: { url }
end end
B->>FE: Click "Publish" B->>FE: Click "Publish"
@@ -123,7 +132,8 @@ sequenceDiagram
BE->>DB: PurchaseRequest.create({status: "pending"}) BE->>DB: PurchaseRequest.create({status: "pending"})
DB-->>BE: savedRequest DB-->>BE: savedRequest
BE->>N: notifyPurchaseRequestCreated(buyer, requestId) BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
par fan-out to sellers (staggered 50ms) par fan-out to sellers (staggered 50ms for DB writes)
BE->>IO: emit 'new-purchase-request' to 'sellers' room (public requests)
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred) BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
BE->>N: createNotification(seller_i, ...) BE->>N: createNotification(seller_i, ...)
N->>IO: emit user-{seller_i} 'new-notification' N->>IO: emit user-{seller_i} 'new-notification'
@@ -131,7 +141,7 @@ sequenceDiagram
end end
BE-->>FE: 201 { request } BE-->>FE: 201 { request }
FE-->>B: Redirect /dashboard/buyer/requests/{id} FE-->>B: Redirect /dashboard/buyer/requests/{id}
IO-->>S1: 'new-notification' (sellers receive in real time) IO-->>S1: 'new-purchase-request' (sellers room) + 'new-notification' (per-user)
``` ```
## API calls ## API calls
@@ -140,33 +150,51 @@ sequenceDiagram
|---|---|---| |---|---|---|
| `POST` | `/api/marketplace/purchase-requests` | Create the request | | `POST` | `/api/marketplace/purchase-requests` | Create the request |
| `GET` | `/api/marketplace/categories` | Step 1 dropdown | | `GET` | `/api/marketplace/categories` | Step 1 dropdown |
| `GET` | `/api/users/sellers` | Step 3 preferred-sellers typeahead | | `GET` | `/api/marketplace/sellers` | Step 3 preferred-sellers typeahead |
| `GET` | `/api/addresses` | Step 4 saved addresses | | `GET` | `/api/addresses` | Step 4 saved addresses |
| `POST` | `/api/files/upload` | Attachments | | `POST` | `/api/marketplace/purchase-requests/:id/attachments` | Attachments upload |
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description | | `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) | | `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) | | `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) | | `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) | | `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
> [!bug] ⚠️ KNOWN BUG — PUT vs PATCH mismatch
> The frontend `updatePurchaseRequest` action sends `PUT /api/marketplace/purchase-requests/:id`, but the backend only registers a `PATCH` handler for that route. The `PUT` call will receive a `404` or `405` response. The backend handler must be updated to also accept `PUT`, or the frontend action must be changed to use `PATCH`.
> [!warning] ⚠️ NOT IMPLEMENTED — Frontend actions with no backend endpoints
> The following frontend actions target backend routes that do not exist:
> - `searchPurchaseRequests` → `GET /marketplace/purchase-requests/search` — this endpoint does not exist. Use query parameters on the standard list endpoint (`GET /api/marketplace/purchase-requests?q=...`) instead.
> - `getMarketplaceStats` → `GET /marketplace/purchase-requests/stats` — this endpoint does not exist. No stats aggregation route is registered.
## Database writes ## Database writes
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[Payment Flow - SHKeeper]], and [[Delivery Confirmation Flow]]. - **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], and [[Delivery Confirmation Flow]].
- **`notifications` collection**: one per notified seller plus one for the buyer. - **`notifications` collection**: one per notified seller plus one for the buyer.
- **`users.referralStats`** is not touched at request creation. - **`users.referralStats`** is not touched at request creation.
## Socket events emitted ## Socket events emitted
- **`new-purchase-request`** → `sellers` room for public purchase requests (shared room, single broadcast; emitted by `notifyAllSellersAboutNewRequest`).
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`). - **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). - **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). Cancellation emits this event with `eventType: 'status-changed'` — there is **no** separate `request-cancelled` event.
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]). - **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
- **`request-cancelled`** → `user-{buyerId}` and `user-{sellerId}` when the buyer cancels (`PurchaseRequestService.ts:671-693`).
### Socket room join/leave events
| Event | Direction | Emitted by |
|---|---|---|
| `join-request-room` | client → server | Buyer detail page on mount (subscribes to `request-{id}`) |
| `join-seller-room` | client → server | `useSellerMarketplaceSocket` on mount |
| `leave-seller-room` | client → server | `useSellerMarketplaceSocket` on unmount |
| `join-buyer-room` | client → server | Buyer socket hook on mount |
| `leave-buyer-room` | client → server | Buyer socket hook on unmount |
## Side effects ## Side effects
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched. - One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`). - The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
- If `urgency === "high"`, the notification message uses the high-priority template — visible in [[Notification Flow]]. - If `urgency === "high"` or `urgency === "urgent"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
## Error / edge cases ## Error / edge cases
@@ -180,13 +208,13 @@ sequenceDiagram
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching. - **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
> [!tip] Status progression is forward-only > [!tip] Status progression is forward-only
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`, `archived`, etc.) and admin tools. > Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`) and admin tools.
## Linked flows ## Linked flows
- [[Seller Offer Flow]] — sellers respond to the published request. - [[Seller Offer Flow]] — sellers respond to the published request.
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`. - [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — buyer pays for the accepted offer. - [[PRD - Request Network In-House Checkout]] — buyer pays for the accepted offer.
- [[Delivery Confirmation Flow]] — seller ships, buyer confirms. - [[Delivery Confirmation Flow]] — seller ships, buyer confirms.
- [[Dispute Flow]] — escape hatch for failed deliveries. - [[Dispute Flow]] — escape hatch for failed deliveries.
- [[Notification Flow]] — backbone of the seller fan-out. - [[Notification Flow]] — backbone of the seller fan-out.

View File

@@ -7,6 +7,11 @@ related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:s
# Rating Flow # Rating Flow
> **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))
> [!caution] Not deeply audited
> This flow was not deeply covered by the 2026-05-29 audit; endpoints should be verified against `reviewRoutes`/`marketplaceController` before relying on them for UAT.
After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`. After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`.
## Actors ## Actors

View File

@@ -7,15 +7,17 @@ related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/refer
# Referral Flow # Referral Flow
> **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))
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]]. Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]].
## Actors ## Actors
- **Referrer** — the user with the code. - **Referrer** — the user with the code.
- **Referred user** — the new sign-up. - **Referred user** — the new sign-up.
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), points routes at `backend/src/routes/pointsRoutes.ts`. - **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), `authController` (`backend/src/services/auth/authController.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`. - **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
- **Socket.IO** — `referral-signup` and `level-up` events. - **Socket.IO** — `referral-signup` (auth domain) and `referral-reward` / `level-up` (points domain) events.
## Preconditions ## Preconditions
@@ -26,17 +28,19 @@ Each user can generate a personal referral code, share a short URL, and earn poi
### 1. Code generation ### 1. Code generation
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate code". 1. User opens the points dashboard. If they don't have a code yet, they receive one automatically (`getUserPoints` lazily generates one — `PointsService.ts:216-219`).
2. Frontend POSTs `POST /api/points/generate-referral-code`. 2. A manual `POST /api/points/generate-referral-code` is also available.
3. `PointsService.generateReferralCode(userId)` (`:12-31`): 3. `PointsService.generateReferralCode(userId)` (`:12-31`):
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`. - Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`.
- Saves the code to the user. - **ALWAYS overwrites** the user's existing code via `User.findByIdAndUpdate(userId, { referralCode: code })` (`:29`). There is **no idempotency / no `force` flag** — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links.
- Returns it. - Returns it.
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button. 4. Frontend renders the share URL `${NEXT_PUBLIC_API_URL}/r/${referralCode}` (pointing to the **backend** API URL, not a frontend URL) and a copy button. This is constructed in `frontend/src/sections/points/points-invite-friends.tsx:35-36`.
> [!warning] Share link points at the wrong base
> The link is built from `NEXT_PUBLIC_API_URL` (the backend) rather than the frontend origin. The `/r/:code` redirect on the backend then bounces the user to the frontend sign-up — so it functions, but the surfaced URL is the API host, which is not the intended public-facing brand URL.
### 2. Short-URL redirect ### 2. Short-URL redirect
5. When a friend clicks the short URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`. 5. When a friend clicks the share URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible). 6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
### 3. Attribution at sign-up ### 3. Attribution at sign-up
@@ -44,26 +48,38 @@ Each user can generate a personal referral code, share a short URL, and earn poi
7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`: 7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`:
- Sets `user.referredBy = referrer._id` on the new user. - Sets `user.referredBy = referrer._id` on the new user.
- Increments `referrer.referralStats.totalReferrals`. - Increments `referrer.referralStats.totalReferrals`.
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total. - Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total — emitted from `authController.ts`, not from PointsService.
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events. 8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
> [!danger] No self-referral guard
> There is **no check** preventing a user from using their own referral code. A user who enters their own code at sign-up (or any flow that sets `referredBy`) is not blocked at the controller or service level. This is a known gap — add a guard such as `if (referrer._id.equals(user._id)) return` in the email and Google sign-up paths.
### 4. Points awarding ### 4. Points awarding
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events: 9. The **only** caller that awards referral points is `marketplaceController.ts`, which invokes `PointsService.processReferralReward(id)` **only when an order transitions to `'completed'`** (`marketplaceController.ts:473-475`, inside `if (newStatus === 'completed')`). It is **NOT** triggered on `'delivered'`, `'delivery'`, `'seller_paid'`, or any other status.
- **Purchase completion** (intended): when a referred user finishes an order, the referrer should get a commission. The hook point is `PurchaseRequestService` `notifyTransactionCompleted` — the exact wiring is implementation-specific; the service exposes `source: 'purchase' | 'referral' | 'bonus' | 'admin'`. 10. `PointsService.processReferralReward(purchaseRequestId)` (`:372-429`):
- **Bonus**: ad-hoc admin grants. - Loads the purchase request, finds the buyer and the buyer's `referredBy` referrer (returns `null` if either is missing).
10. Inside `addPoints`: - Computes `referralPoints = Math.floor(amount * 0.02)` — a flat **2% commission** on the selected offer's price.
- Calls `PointsService.addPoints(referrerId, referralPoints, 'referral', {...})`.
- Recomputes `referrer.referralStats.activeReferrals` as a count of **ALL** users with `referredBy = referrer._id` (`:409-411`) — this includes referrals that never purchased; it is **not** scoped to converted referrals.
- Increments `referrer.referralStats.totalEarned`.
- Emits **`referral-reward`** to `user-{referrerId}` (`:417`).
11. Inside `addPoints` (`:36-113`):
- Transaction-scoped Mongo session. - Transaction-scoped Mongo session.
- `user.points.total += amount; user.points.available += amount`. - `user.points.total += amount; user.points.available += amount`.
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`. - `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`. For `source === 'referral'`, `metadata.commission` is set to the amount.
- `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`. - `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`). - Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`).
11. Both the referrer and the referee may earn points (e.g. "give 100, get 100" growth model). The current code awards per `addPoints` call — design decision lives in the caller, not in PointsService. 12. Note: only the **referrer** earns points via this path. There is no "referee also earns" reward in the current code — the referee gets nothing automatically.
### 5. Redemption / payout ### 5. Redemption
12. Users see their balance under `/dashboard/account/points` and can spend via `POST /api/points/redeem` (e.g. for service-credit or discount codes). 13. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running. 14. `redeemPoints(userId, pointsToUse, purchaseRequestId)` (`:118-167`):
- Requires both `purchaseRequestId` and `pointsToUse` (controller returns `400` if either is missing or `pointsToUse <= 0`).
- Throws `Insufficient points` if `user.points.available < pointsToUse`.
- Decrements `available`, increments `used`, and records a `PointTransaction` with `type: 'spend'`, `source: 'redemption'`.
- The controller computes `discount = pointsToUse * 1000` (1 point = 1000 IRR, **always**) and returns `{ transaction, discount, remainingPoints }`. There are **no** `amount` / `purpose` / `newBalance` / `redemption` fields in the response.
## Sequence diagram ## Sequence diagram
@@ -77,11 +93,11 @@ sequenceDiagram
participant DB as MongoDB participant DB as MongoDB
participant IO as Socket.IO participant IO as Socket.IO
R->>FE: Generate referral code R->>FE: Generate referral code (or auto-assigned)
FE->>BE: POST /api/points/generate-referral-code FE->>BE: POST /api/points/generate-referral-code
BE->>DB: User.findByIdAndUpdate(referralCode=...) BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
BE-->>FE: { code } BE-->>FE: { referralCode }
R->>R: share https://amn.gg/r/{code} R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
N->>BE: GET /r/{code} N->>BE: GET /r/{code}
BE-->>N: 302 → /auth/jwt/sign-up?ref={code} BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
@@ -89,67 +105,96 @@ sequenceDiagram
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification) FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
BE->>DB: User.create BE->>DB: User.create
BE->>DB: referrer.referralStats.totalReferrals += 1 BE->>DB: referrer.referralStats.totalReferrals += 1
BE->>IO: emit user-{R} 'referral-signup' BE->>IO: emit user-{R} 'referral-signup' (authController)
Note over BE,DB: Later, when N completes a purchase Note over BE,DB: ONLY when N's order reaches status 'completed'
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N}) BE->>BE: marketplaceController → PointsService.processReferralReward(id)
BE->>DB: add X points to user balance BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
BE->>DB: create PointTransaction record BE->>DB: add points to balance + create PointTransaction
BE->>BE: updateUserLevel → maybe 'level-up' BE->>BE: updateUserLevel → maybe 'level-up'
BE->>IO: emit user-{R} 'level-up' BE->>IO: emit user-{R} 'level-up'
BE->>DB: activeReferrals = count(referredBy=R) (ALL, not just buyers)
BE->>IO: emit user-{R} 'referral-reward' (PointsService)
``` ```
## API calls ## API calls
| Method | Endpoint | Purpose | > [!note] All points routes require authentication
|---|---|---| > `router.use(authenticateToken)` is applied to **every** route in `pointsRoutes.ts:8`. None of these endpoints — including `GET /api/points/levels` — are public.
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
| `GET` | `/api/points/my-points` | Balance + level | | Method | Endpoint | Auth | Body / Query | Response data |
| `GET` | `/api/points/transactions` | History | |---|---|---|---|---|
| `GET` | `/api/points/referrals` | Referred users list | | `POST` | `/api/points/generate-referral-code` | user | (ignored) | `{ referralCode }` — always rotates the code |
| `GET` | `/api/points/leaderboard` | Global top referrers | | `GET` | `/api/points/my-points` | user | — | `{ points, referral, currentLevel, nextLevel }` |
| `GET` | `/api/points/levels` | Level config (public) | | `GET` | `/api/points/transactions` | user | `page`, `limit`, `type` (`earn`/`spend`/`expire` only) | `{ transactions, pagination }` |
| `POST` | `/api/points/redeem` | Spend points | | `GET` | `/api/points/referrals` | user | `page`, `limit` | `{ referrals, pagination }` |
| `POST` | `/api/points/admin/add` | Admin-only manual grant | | `GET` | `/api/points/leaderboard` | user | `limit` only (**`period` is NOT supported**) | `{ leaderboard, total }` |
| `GET` | `/r/:code` | Short-URL redirect to sign-up | | `GET` | `/api/points/levels` | user (**NOT public**) | — | `{ levels }` |
| `POST` | `/api/points/redeem` | user | `{ pointsToUse, purchaseRequestId }` (both required) | `{ transaction, discount, remainingPoints }` |
| `POST` | `/api/points/admin/add` | admin | `{ userId, amount, description }` | `{ transaction, user, levelChanged, newLevel }` |
| `GET` | `/r/:code` | public | — | `302` redirect to sign-up |
### Endpoint notes (verified against code)
- **`GET /api/points/transactions``type` filter** only accepts `earn`, `spend`, or `expire` (`PointsService.ts:250-265`). There is **no source-based filtering**: you cannot filter by `referral` / `purchase` / `admin` / `redemption`.
- **`GET /api/points/leaderboard` — the `period` filter (`all`/`month`/`week`) does not exist and is silently ignored.** `getLeaderboard(limit)` only honors `limit` and always returns all-time data sorted by `totalReferrals` then `totalEarned` (`PointsService.ts:434-479`).
- **`POST /api/points/admin/add`** reads `{ userId, amount, description }` (the field is `description`, **not** `reason`). However the `description` is **read but never persisted** — the controller calls `addPoints(userId, amount, 'admin', {})` with an empty metadata object (`pointsController.ts:209`), so admin-granted points store **no human-readable reason**. The stored description is the generic auto-generated `'admin'` label from `getTransactionDescription`.
## Database writes ## Database writes
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events. - **`users`**: `referralCode` on generation/rotation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, used, level}` on point events. `activeReferrals` is set by `PointsService.processReferralReward` (`:409`) as a count of **all** users with `referredBy = referrer._id`, regardless of purchase history.
- **`pointtransactions`**: one document per earn/spend/refund. - **`pointtransactions`**: one document per `earn` / `spend` event. (`expire` is defined in the schema but **never written** — see below.)
- **`levelconfigs`**: read-only at runtime (seeded at deploy). - **`levelconfigs`**: read-only at runtime (seeded at deploy).
## Socket events emitted ## Socket events emitted
- **`referral-signup`** → `user-{referrerId}` on referee creation. - **`referral-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
- **`level-up`** → `user-{userId}` when crossing a tier. - **`referral-reward`** → `user-{referrerId}` when `PointsService.processReferralReward` runs — emitted by `PointsService.ts:417`; this is the **points-domain** event. (There is no `referral-signup` emitted from PointsService.)
- **`new-notification`** → standard notification channel for points-related milestones. - **`level-up`** → `user-{userId}` when crossing a tier (`PointsService.ts:92`).
## Side effects ## Side effects
- The referee never sees the referrer's identity unless surfaced in UI. - `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers); `points.used` tracks redeemed points.
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers). - Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`, `redeemPoints:123-153`).
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
## Error / edge cases ## Error / edge cases
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee. - **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee.
- **Self-referral** — not blocked at controller level. Add a check `if (referrer._id.equals(user._id)) return` in `verifyEmailWithCode` and `googleSignUp` to prevent gaming. - **Self-referral** — **NOT blocked** at any level (see danger callout above). Known gap.
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`). - **Code rotation on regenerate** — calling `generate-referral-code` again replaces the existing code, breaking previously shared links. There is no opt-out.
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed. Soft-delete preservation is acceptable. - **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed.
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice. - **Point expiry never enforced** — the `expiresAt` field and the `'expire'` transaction type exist in the schema, and there is a sparse index for expiry sweeps, but **no cron job, TTL index, or service ever creates `expire`-type transactions**. Points never actually expire today.
- **Race on level-up** — the Mongo session ensures `user.points` and `PointTransaction` are atomically updated, but two parallel `addPoints` calls might both trigger level-up emit. Idempotent in practice (frontend shows toast once). - **`activeReferrals` semantics** — counts **all** referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
- **`activeReferrals`** — defined in `referralStats` but no code path increments it currently. Define "active" (e.g. referee has at least one completed purchase) and update accordingly.
> [!tip] Track conversion, not just sign-ups > [!tip] Track conversion, not just sign-ups
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value. > `totalReferrals` is incremented on sign-up and `activeReferrals` counts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicated `convertedReferrals` counter incremented only inside `processReferralReward`.
## Frontend coverage (known gaps)
The following routes are referenced conceptually but **do NOT exist** — navigating to them returns **404**:
- `/dashboard/points/referrals` — 404 (no page file)
- `/dashboard/points/transactions` — 404 (no page file)
- `/dashboard/points/levels` — 404 (no page file)
Only `/dashboard/points` (`frontend/src/app/dashboard/points/page.tsx`) exists.
The following frontend actions are defined in `frontend/src/actions/points.ts` but have **no UI callers** (dead code from the UI's perspective):
- `redeemPoints` — no caller.
- `generateReferralCode` — no caller (codes are auto-assigned server-side via `getUserPoints`).
- `getLevels` — no caller.
- `getReferrals` — no caller.
- `adminAddPoints` — no caller.
Only `getMyPoints`, `getTransactions`, and `getLeaderboard` are actually invoked by the UI (`points-main-view.tsx`, `points-leaderboard.tsx`).
## Linked flows ## Linked flows
- [[Registration Flow]] — attribution point. - [[Registration Flow]] — attribution point.
- [[Google OAuth Flow]] — also supports `referralCode`. - [[Google OAuth Flow]] — also supports `referralCode`.
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here. - [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface here.
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission. - [[Escrow Flow]] — order reaching `'completed'` is the **sole** trigger for awarding referral commission.
## Source files ## Source files
@@ -158,7 +203,8 @@ sequenceDiagram
- Backend: `backend/src/routes/pointsRoutes.ts` - Backend: `backend/src/routes/pointsRoutes.ts`
- Backend: `backend/src/models/PointTransaction.ts` - Backend: `backend/src/models/PointTransaction.ts`
- Backend: `backend/src/models/LevelConfig.ts` - Backend: `backend/src/models/LevelConfig.ts`
- Backend: `backend/src/services/auth/authController.ts:411-433` (referral attribution on email signup) - Backend: `backend/src/services/marketplace/marketplaceController.ts:473-475` (referral reward triggered ONLY on `'completed'`)
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup) - Backend: `backend/src/services/auth/authController.ts` (referral attribution + `referral-signup` emit on email/Google signup)
- Backend: `backend/src/app.ts:274-278` (short-URL redirect) - Backend: `backend/src/app.ts:274-278` (short-URL redirect)
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`) - Frontend: `frontend/src/sections/points/points-invite-friends.tsx:35-36` (builds share URL from `NEXT_PUBLIC_API_URL`)
- Frontend: `frontend/src/actions/points.ts` (action layer; several actions have no UI callers)

View File

@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "P
# Registration Flow # Registration Flow
> **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))
End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance. End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
## Actors ## Actors
@@ -53,10 +55,10 @@ stateDiagram-v2
1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`). 1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`).
2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account". 2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
> [!tip] Password is **not** sent to `/register` > [!bug] ⚠️ KNOWN BUG / quirk — the sign-up form does not collect the real password
> The password is only included in the second step (`/verify-email-code`). The intent: never hash and store a password for an unverified account. The TempVerification document carries `password: ''` until verification. > `jwt-sign-up-view.tsx` `onSubmit` calls `signUp({ ..., password: '' })` with a **hard-coded empty string** (`jwt-sign-up-view.tsx:191`, with the inline comment `// You might need to add password field to form`). So the actual password is **not** collected on the sign-up form at all — it is collected at the **email-verification step** (`/verify-email-code`). The `TempVerification.password` field is effectively **unused** (it is set to `''` and never read as a real credential). The credential that ends up on the `User` is the one entered at verification.
3. **HTTP request**: `POST /api/auth/register` with `{ email, password?, firstName?, lastName?, role, referralCode? }`. (The frontend currently passes the password through, but the controller stores `''` regardless — see `authController.ts:123`.) 3. **HTTP request**: `POST /api/auth/register` with `{ email, password: '', firstName?, lastName?, role, referralCode? }`. The frontend passes `password: ''` (empty string) — see the quirk above. The controller persists this empty string into `TempVerification.password`, which is never used as a real credential.
4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum. 4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum.
5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`. 5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`.
6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min). 6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
@@ -74,10 +76,11 @@ stateDiagram-v2
15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`. 15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`.
16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`. 16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`.
17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`. 17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`.
18. **Apply referral** (`authController.ts:411-433`): if `tempVerification.referralCode` exists, find the referrer by `User.findOne({ referralCode })`. If found: 18. **Apply referral** (`authController.ts:691-713`): `tempVerification.referralCode` (stored on the `TempVerification` document at registration and applied here at verification) is looked up via `User.findOne({ referralCode })`. If a referrer is found:
- `user.referredBy = referrer._id` - `user.referredBy = referrer._id`
- `referrer.referralStats.totalReferrals += 1` - `referrer.referralStats.totalReferrals += 1`
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase. - Emit `referral-signup` on `user-${referrer._id}` Socket.IO room (`authController.ts:704`; the equivalent Google/other path emits at `authController.ts:1132`) — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
- ⚠️ **No self-referral guard**: the code only checks `if (referrer)` — it never compares `referrer._id` to the newly created user. A user who somehow signs up with their own `referralCode` would be attributed as their own referrer.
19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`). 19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`).
20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`. 20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`.
21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`). 21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`).
@@ -139,9 +142,9 @@ sequenceDiagram
## Database writes ## Database writes
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification. - **`tempverifications` collection**: insert on first POST (carrying `email`, `password: ''`, `firstName`, `lastName`, `role`, `referralCode`, code + expiry), in-place update on duplicate POST, delete on successful verification.
- **`users` collection**: full insert on successful verification (`authController.ts:400-435`). The first refresh token is appended in the same save. - **`users` collection**: full insert on successful verification (`authController.ts:680-688`). The first refresh token is appended in the same save.
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:419`). - **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:699`).
## Socket events emitted ## Socket events emitted
@@ -149,7 +152,7 @@ sequenceDiagram
``` ```
{ userId, userName, userEmail, timestamp, totalReferrals } { userId, userName, userEmail, timestamp, totalReferrals }
``` ```
Source: `authController.ts:423-431`. Source: `authController.ts:704-710` (and `:1132` on the parallel path).
## Side effects ## Side effects
@@ -168,6 +171,7 @@ sequenceDiagram
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup. - **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup.
- **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam. - **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam.
- **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`. - **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`.
- **Self-referral** → **not guarded**. The referral attribution (`authController.ts:691-713`) only checks that a referrer exists, never that it differs from the signing-up user.
- **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`. - **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`.
- **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record. - **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record.
- **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow. - **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow.

View File

@@ -2,12 +2,14 @@
title: Seller Offer Flow title: Seller Offer Flow
tags: [flow, marketplace, seller, offer] tags: [flow, marketplace, seller, offer]
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"] related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/request/:requestId", "PATCH /api/marketplace/offers/:id"] related_apis: ["POST /api/marketplace/purchase-requests/:id/offers", "GET /api/marketplace/purchase-requests/:id/offers", "PATCH /api/marketplace/offers/:id"]
--- ---
> **Last updated:** 2026-05-30 — updated for offer-management page, `withdrawOffer` action, edit-while-pending, `getSellerOffers` API (commits 240a668e7d1375)
# Seller Offer Flow # Seller Offer Flow
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[Payment Flow - SHKeeper]]) or reject. A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject.
## Actors ## Actors
@@ -23,7 +25,7 @@ A **seller** browses open purchase requests and submits an offer with a price, d
## Preconditions ## Preconditions
- Seller is authenticated, `role === "seller"`, `status === "active"`. - Seller is authenticated, `role === "seller"`, `status === "active"`.
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`). - Target purchase request exists and `status` is `pending`, `received_offers`, or `active` (`SellerOfferService.ts:83-85`).
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`). - Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
## Offer state machine ## Offer state machine
@@ -31,17 +33,16 @@ A **seller** browses open purchase requests and submits an offer with a price, d
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> pending: createOffer() [*] --> pending: createOffer()
pending --> active: (optional — manual seller activation)
pending --> withdrawn: seller withdraws (only while pending) pending --> withdrawn: seller withdraws (only while pending)
pending --> rejected: another offer accepted\nor buyer rejects this one pending --> rejected: another offer accepted\nor buyer rejects this one
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook pending --> accepted: acceptOffer()\nor payment confirmed
accepted --> [*] accepted --> [*]
rejected --> [*] rejected --> [*]
withdrawn --> [*] withdrawn --> [*]
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron) pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
``` ```
The active enum values are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). `validUntil` expirations are converted to `withdrawn`. The valid `SellerOffer` statuses are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). There is **no** `active` status for `SellerOffer`. `validUntil` expirations are converted to `withdrawn`.
## Step-by-step narrative ## Step-by-step narrative
@@ -60,10 +61,10 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
- **Delivery time** (amount + unit: hours / days / weeks) - **Delivery time** (amount + unit: hours / days / weeks)
- **Attachments** (optional, via `POST /api/files/upload`) - **Attachments** (optional, via `POST /api/files/upload`)
- **Valid until** (optional expiry) - **Valid until** (optional expiry)
5. Frontend POSTs `POST /api/marketplace/offers`. 5. Frontend POSTs `POST /api/marketplace/purchase-requests/:id/offers` (the `purchaseRequestId` is a **path parameter**, not a body field).
6. Backend `SellerOfferService.createOffer` (`:51-140`): 6. Backend `SellerOfferService.createOffer` (`:51-140`):
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد داده‌اید"` (`:74`). Use `updateOffer` to amend. - **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد داده‌اید"` (`:74`). Use `updateOffer` to amend.
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending` or `received_offers`. - **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending`, `received_offers`, or `active`.
- Saves the offer (`status: "pending"` by default in the schema). - Saves the offer (`status: "pending"` by default in the schema).
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response. - Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly. 7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
@@ -73,24 +74,38 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
### Buyer review ### Buyer review
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/offers/request/{requestId}``SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`. 11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/purchase-requests/:id/offers``SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes. 12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment. 13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
### Accept → Payment ### Accept / Select Offer → Payment
14. The buyer's "Pay this offer" button kicks off [[Payment Flow - SHKeeper]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; the SHKeeper webhook does that atomically when the on-chain payment is confirmed. 14. The buyer selects an offer via `POST /api/marketplace/purchase-requests/:id/select-offer`. **Important**: this endpoint fires only a generic `purchase-request-update` event to the `request-{requestId}` room. No per-seller socket events or notifications are sent to the winning or losing sellers at this stage.
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`): 15. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed.
16. On Request Network payment confirmation:
- The selected offer's `status``accepted`. - The selected offer's `status``accepted`.
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`. - All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`. - The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
- A direct chat is created (see [[Chat Flow]]). - A direct chat is created (see [[Chat Flow]]).
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path). - Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
- Socket events: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`). - Socket events notify the winner and reject/close competing offers.
### Withdrawal ### Edit / withdrawal while awaiting buyer acceptance
16. Seller can withdraw their `pending` offer from `/dashboard/seller/marketplace/offers/{offerId}``withdrawOffer` (`SellerOfferService.ts:428-443`). The DB filter `{ status: 'pending' }` means withdrawal is impossible once `accepted` or `rejected`. 17. While a request is in `received_offers` status (buyer has not yet accepted), the seller may **edit** their pending offer or **withdraw** it entirely from the request-detail step-2 card (`step-2-waiting-for-payment.tsx`).
- **Edit**: toggles `mode` to `'edit'` inside `Step2WaitingForPayment`, re-mounts `Step1SendProposal` pre-populated with the existing offer values. On save, calls `PATCH /api/marketplace/offers/:id` (via `updateOffer` action, which now correctly uses `PATCH` instead of the old `PUT`).
- **Withdraw**: opens a `ConfirmDialog`, then calls `withdrawOffer(offerId)` in `src/actions/marketplace.ts` which uses `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
`canManageOffer` is only `true` when `requestDetails?.status === 'received_offers'`; once the buyer accepts and the status advances, both buttons are hidden.
The DB filter `{ status: 'pending' }` inside `SellerOfferService.withdrawOffer` means withdrawal is impossible once `accepted` or `rejected`.
> ⚠️ `POST /api/marketplace/offers/:id/withdraw` still does **not** exist as an HTTP route. Always use `PUT /api/marketplace/offers/:id/status` with `{ status: 'withdrawn' }`.
### Offer update — method mismatch resolved
> ✅ **Fixed (commit 240a668)**: The frontend `updateOffer` action now sends `PATCH /api/marketplace/offers/:id`, matching the backend. The `acceptOffer` action was also corrected from `PUT` to `PATCH`.
## Sequence diagram ## Sequence diagram
@@ -110,7 +125,7 @@ sequenceDiagram
FE_S->>BE: GET /api/marketplace/purchase-requests FE_S->>BE: GET /api/marketplace/purchase-requests
BE-->>FE_S: filtered request list BE-->>FE_S: filtered request list
S->>FE_S: Open request and send offer S->>FE_S: Open request and send offer
FE_S->>BE: POST /api/marketplace/offers FE_S->>BE: POST /api/marketplace/purchase-requests/:id/offers
BE->>DB: Validate offer not duplicate BE->>DB: Validate offer not duplicate
BE->>DB: Validate request status BE->>DB: Validate request status
BE->>DB: Create offer with status pending BE->>DB: Create offer with status pending
@@ -119,15 +134,15 @@ sequenceDiagram
end end
BE->>N: notifyNewOfferReceived BE->>N: notifyNewOfferReceived
N->>IO: emit notification to buyer N->>IO: emit notification to buyer
BE->>IO: emit seller new-offer BE->>IO: emit new-offer to buyer-{buyerId}
BE-->>FE_S: 200 { offer } BE-->>FE_S: 200 { offer }
IO-->>FE_B: notify buyer bell icon IO-->>FE_B: notify buyer bell icon
B->>FE_B: Open request detail B->>FE_B: Open request detail
FE_B->>BE: GET /api/marketplace/offers/request/{id} FE_B->>BE: GET /api/marketplace/purchase-requests/:id/offers
BE-->>FE_B: offers BE-->>FE_B: offers
alt alt
B->>FE_B: Click pay to finish selected offer B->>FE_B: Click pay to finish selected offer
B->>FE_B: SHKeeper webhook handles payment result B->>FE_B: Request Network payment confirms
else else
B->>FE_B: Open chat to negotiate B->>FE_B: Open chat to negotiate
end end
@@ -135,15 +150,15 @@ sequenceDiagram
## API calls ## API calls
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose | Notes |
|---|---|---| |---|---|---|---|
| `POST` | `/api/marketplace/offers` | Create offer | | `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer | `purchaseRequestId` is a path param |
| `GET` | `/api/marketplace/offers/request/:requestId` | Buyer view of offers on a request | | `GET` | `/api/marketplace/purchase-requests/:id/offers` | Buyer view of offers on a request | |
| `GET` | `/api/marketplace/offers/seller/:sellerId` | Seller's own offer history | | `GET` | `/api/marketplace/offers/:id` | Single offer details | |
| `GET` | `/api/marketplace/offers/:id` | Single offer details | | `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | Fixed: frontend now sends `PATCH` |
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) | | `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | |
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) | | `GET` | `/api/marketplace/offers/seller/:sellerId` | All offers by this seller (used by Offer Management page) | Implemented via `getSellerOffers` frontend action (commit 240a668) |
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller withdraws | | `PUT` | `/api/marketplace/offers/:id/status` | Status mutation — use `{ status: 'withdrawn' }` to withdraw | The only HTTP withdraw path; `POST /api/marketplace/offers/:id/withdraw` does **not** exist |
## Database writes ## Database writes
@@ -154,8 +169,10 @@ sequenceDiagram
## Socket events emitted ## Socket events emitted
- **`seller-offer-update`** with `eventType: 'new-offer'``seller-{sellerId}` (creator's other tabs). - **`seller-offer-update`** with `eventType: 'new-offer'``seller-{sellerId}` (creator's other tabs).
- **`new-offer`** → `buyer-{buyerId}` room — emitted directly by `marketplaceController.ts` on offer creation; `use-marketplace-socket.ts` (lines 300, 497) listens on this event to update the buyer's offer list in real time.
- **`purchase-request-update`** with `eventType: 'offer-updated'``request-{requestId}` on edits (`SellerOfferService.ts:284-288`). - **`purchase-request-update`** with `eventType: 'offer-updated'``request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler). - **`purchase-request-update`** → `request-{requestId}` when buyer calls `select-offer` (generic room event only — no per-seller notifications or events are sent to winning or losing sellers).
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler after payment confirmation).
- **`new-notification`** → `user-{buyerId}` for each new offer. - **`new-notification`** → `user-{buyerId}` for each new offer.
## Side effects ## Side effects
@@ -171,7 +188,7 @@ sequenceDiagram
- **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state). - **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state).
- **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`. - **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`.
- **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`. - **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`.
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, the SHKeeper webhook coordinator (`PaymentCoordinator`) is idempotent and the first PAID wins. - **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, `PaymentCoordinator` and provider idempotency decide which confirmed payment wins.
- **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans. - **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans.
> [!tip] Real-time UX > [!tip] Real-time UX
@@ -181,7 +198,7 @@ sequenceDiagram
- [[Purchase Request Flow]] — produces the requests sellers offer on. - [[Purchase Request Flow]] — produces the requests sellers offer on.
- [[Negotiation Flow]] — counter-offer in `in_negotiation`. - [[Negotiation Flow]] — counter-offer in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — locks in the accepted offer. - [[PRD - Request Network In-House Checkout]] — locks in the accepted offer.
- [[Chat Flow]] — direct chat opened after payment. - [[Chat Flow]] — direct chat opened after payment.
- [[Notification Flow]] — channels for offer events. - [[Notification Flow]] — channels for offer events.
- [[Rating Flow]] — seller's average rating displayed in the offer card. - [[Rating Flow]] — seller's average rating displayed in the offer card.
@@ -191,7 +208,10 @@ sequenceDiagram
- Backend: `backend/src/services/marketplace/SellerOfferService.ts` - Backend: `backend/src/services/marketplace/SellerOfferService.ts`
- Backend: `backend/src/services/marketplace/marketplaceController.ts` - Backend: `backend/src/services/marketplace/marketplaceController.ts`
- Backend: `backend/src/models/SellerOffer.ts` - Backend: `backend/src/models/SellerOffer.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook) - Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` - Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` — proposal form (also re-used for edit)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-2-waiting-for-payment.tsx` — awaiting-buyer card with edit/withdraw actions
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` - Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
- Frontend: `frontend/src/app/dashboard/seller/marketplace/` - Frontend: `frontend/src/app/dashboard/seller/marketplace/` — seller marketplace browse
- Frontend: `frontend/src/app/dashboard/seller/marketplace/offers/page.tsx` — Offer Management page (all offers, status filter, withdraw)
- Frontend: `frontend/src/actions/marketplace.ts``withdrawOffer`, `getSellerOffers` actions

View File

@@ -1,24 +1,39 @@
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
# Trezor Safekeeping Flow # Trezor Safekeeping Flow
This flow adds hardware-backed custody controls without replacing the current payment model. The backend never stores private keys. Trezor support starts as a single hardware signer and is designed to upgrade to multisig later. This flow adds hardware-backed custody controls without replacing the current payment model. The backend never stores private keys. Trezor support starts as a single hardware signer and is designed to upgrade to multisig later.
Default mode: optional. Existing release/refund flows do not require Trezor proof unless `TREZOR_SAFEKEEPING_REQUIRED=true`. Default mode: optional. Existing release/refund flows do not require Trezor proof unless `TREZOR_SAFEKEEPING_REQUIRED=true`.
> **Note (corrected 2026-05-29):** The frontend Trezor implementation **does exist** in current code — the 2026-05-29 audit's "zero frontend implementation" claim was based on an older snapshot. The active surface is:
> - `src/app/dashboard/admin/trezor/page.tsx` → `TrezorSettingsView` (registration + re-register UI)
> - `src/web3/trezor/trezorConnector.ts` → lazy-imports `@trezor/connect-web` (`trezorGetXpub`, `trezorGetAddress`, `trezorSignMessage`)
> - `src/components/trezor-sign-dialog/TrezorSignDialog.tsx` → build-instruction → sign-on-Trezor → enter-txHash → confirm
> - `src/actions/trezor.ts` → full API client (`getTrezorAccount`, `getTrezorRegistrationMessage`, `registerTrezor`, `getTrezorOperationMessage`, `confirmRelease`/`confirmRefund`) that **builds the `trezor: { message, signature }` object**
>
> The legacy `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }` (no `trezor` field), but they have **no UI callers** — the active admin release/refund path goes through `TrezorSignDialog` → `actions/trezor.ts`, which satisfies the `assertTrezorSignatureForOperation` guard when `TREZOR_SAFEKEEPING_REQUIRED=true`.
## Goals ## Goals
- Generate a fresh receive address per user/payment from a registered Trezor xpub. - Generate a fresh receive address per user/payment from a registered Trezor xpub.
- Require a Trezor-produced signature before release/refund confirmation when safekeeping enforcement is enabled. - Require a Trezor-produced signature before release/refund confirmation when safekeeping enforcement is enabled.
- Keep SHKeeper and Request Network optional provider paths intact. - Keep the Request Network payment adapter and legacy provider abstractions intact while adding custody controls.
- Preserve the existing `Payment` model and orchestration surface. - Preserve the existing `Payment` model and orchestration surface.
## Actors
- **Admin** — the only party who can request operation messages and submit verify-operation calls. The registered Trezor must belong to an admin account; the safekeeping guard validates against the admin's `TrezorAccount.registrationAddress`.
- **Any authenticated user** — may call `POST /api/trezor/register` (no role restriction on that endpoint).
## Registration ## Registration
1. User connects a Trezor in the frontend and exports an Ethereum account xpub, for example `m/44'/60'/0'`. 1. The Trezor owner (typically an admin) connects a Trezor and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
2. Backend builds a registration challenge: 2. Backend builds a registration challenge:
- `GET /api/trezor/registration-message?xpub=...&registrationAddress=...` - `GET /api/trezor/registration-message?xpub=...&registrationAddress=...`
3. The registration address must be the first derived address from the xpub: 3. The registration address must be the first derived address from the xpub:
- `m/44'/60'/0'/0/0` - `m/44'/60'/0'/0/0`
4. User signs the challenge with that Trezor address. 4. The owner signs the challenge with that Trezor address.
5. Frontend submits: 5. Frontend submits:
- `POST /api/trezor/register` - `POST /api/trezor/register`
- `xpub` - `xpub`
@@ -30,14 +45,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
- xpub is public, not private. - xpub is public, not private.
- registration address matches xpub-derived index `0`. - registration address matches xpub-derived index `0`.
- signature recovers the registration address. - signature recovers the registration address.
7. Backend stores only: 7. Backend stores / updates the `TrezorAccount` record. **Upsert behaviour:** if a record already exists for the user, `xpub`, `basePath`, and `label` are updated, but `nextAddressIndex` and the existing `addresses` array are preserved via `$setOnInsert`. Old address records continue to reference the previous xpub — a xpub mismatch is therefore possible after re-registration.
- `userId`
- xpub fingerprint
- xpub
- base derivation path
- registration address
- next address index
- issued address records
## Address Generation ## Address Generation
@@ -51,6 +59,15 @@ POST /api/trezor/addresses/next
} }
``` ```
Valid values for `purpose` (as enumerated in the schema):
| Value | Description |
|---|---|
| `deposit` | Incoming payment address |
| `release` | Address used in a release operation |
| `refund` | Address used in a refund operation |
| `other` | General-purpose address |
The backend derives non-hardened receive addresses from the registered xpub: The backend derives non-hardened receive addresses from the registered xpub:
```text ```text
@@ -59,9 +76,9 @@ m/44'/60'/0'/0/{index}
If a `paymentId` already has an address, the endpoint returns the same address instead of incrementing the index. If a `paymentId` already has an address, the endpoint returns the same address instead of incrementing the index.
## Transaction Approval ## Transaction Approval (Admin-only)
Before a release/refund confirmation, the admin asks the backend for the exact operation message: `POST /api/trezor/operation-message` and `POST /api/trezor/verify-operation` are admin-only endpoints. Before a release/refund confirmation, the admin asks the backend for the exact operation message:
```http ```http
POST /api/trezor/operation-message POST /api/trezor/operation-message
@@ -75,19 +92,17 @@ POST /api/trezor/operation-message
} }
``` ```
The Trezor signs that message. Release/refund confirmation then includes: The Trezor signs that message and the admin submits it. **The frontend implements this flow** via `TrezorSignDialog`, which calls `getTrezorOperationMessage()`, prompts the Trezor to sign, and then submits the release/refund confirmation through `confirmRelease()` / `confirmRefund()` in `src/actions/trezor.ts` with the full payload:
```json ```json
{ {
"txHash": "0x...", "txHash": "0x...",
"trezor": { "amount": 100,
"message": "Amanat escrow Trezor transaction approval\n...", "trezor": { "message": "<canonical operation message>", "signature": "0x..." }
"signature": "0x..."
}
} }
``` ```
When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verifies the signature before calling the payment adapter confirmation path. The `trezor` object is included whenever a signature was produced, satisfying the backend `assertTrezorSignatureForOperation` guard. (The older `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }`, but they are unused legacy code with no UI callers.)
## Enforcement Flag ## Enforcement Flag
@@ -95,7 +110,25 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
TREZOR_SAFEKEEPING_REQUIRED=false TREZOR_SAFEKEEPING_REQUIRED=false
``` ```
Default is permissive so existing SHKeeper and Request Network flows continue to work. Set it to `true` only after registering the operating admin's Trezor account and testing the signing path. Any value other than the literal string `true` is treated as disabled. Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account (the frontend signing flow via `TrezorSignDialog` is already implemented). Any value other than the literal string `true` is treated as disabled.
## Break-Glass Mode (Emergency Bypass)
When `TREZOR_SAFEKEEPING_REQUIRED=true` but the Trezor device is unavailable (lost, hardware fault, key-holder absent), an admin can activate **break-glass mode** to temporarily bypass the safekeeping requirement:
| Endpoint | Action |
|---|---|
| `GET /api/admin/settings/break-glass` | Read current status (`active`, `expiresAt`, `activatedBy`) |
| `POST /api/admin/settings/break-glass` | Activate for **1 hour** — fires a Telegram alarm immediately |
| `DELETE /api/admin/settings/break-glass` | Cancel before expiry |
**Properties:**
- State is in-memory only (resets on server restart — intentional).
- Activation fires a Telegram alert via `tgNotify` regardless of `TG_NOTIFY_BOT_TOKEN` set status.
- The exported `isBreakGlassActive()` helper is called by `assertTrezorSignatureForOperation` — when `true`, the signature check is skipped.
- Maximum duration: 1 hour. After expiry the guard is automatically re-enabled.
**Source:** `backend/src/services/admin/breakGlassRoutes.ts` (commit `b21df25`).
## Safety Rules ## Safety Rules
@@ -108,7 +141,7 @@ Default is permissive so existing SHKeeper and Request Network flows continue to
## Upgrade Path To Multisig ## Upgrade Path To Multisig
The current design stores a single `trezor-eoa` signer. Later, replace the signer policy with: The current design stores a single `trezor-eoa` signer. The recommended production path is to replace the signer policy with:
- `addressType: safe-multisig` - `addressType: safe-multisig`
- a Safe address per tenant/admin group - a Safe address per tenant/admin group
@@ -116,4 +149,4 @@ The current design stores a single `trezor-eoa` signer. Later, replace the signe
- Trezor owners as Safe signers - Trezor owners as Safe signers
- release/refund flow creates a Safe transaction and records collected signatures before execution - release/refund flow creates a Safe transaction and records collected signatures before execution
The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry. The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] for the staged Safe-first path before any custom escrow contract.

View File

@@ -2,12 +2,26 @@
title: Colors title: Colors
tags: [design-system, colors, palette] tags: [design-system, colors, palette]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Colors # Colors
The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.** The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.**
> [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> As of v2.7.0 the active palette is the **Amaneh warm-earth** preset. The color presets menu in the settings drawer has been simplified to a single Amaneh entry; the multi-swatch picker was removed. The canonical palette names are:
> - **Saffron** — `primary` (golden-amber)
> - **Pistachio** — `success` (soft green)
> - **Persian Blue** — `info` (deep indigo-blue)
> - **Honey** — `warning` (amber-gold)
> - **Pomegranate** — `error` (deep red)
> - **Cream paper** — `background.paper`
> - **Parchment** — `background.default`
> - **Warm Ink** — `text.primary`
>
> CSS custom properties under `--amn-*` are defined in `src/app/global.css` and mirror these tokens for non-MUI elements.
> [!warning] > [!warning]
> Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`. > Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`.

View File

@@ -2,10 +2,14 @@
title: Design System Overview title: Design System Overview
tags: [design-system, ui, mui] tags: [design-system, ui, mui]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Design System Overview # Design System Overview
> [!info] Current version: **Amaneh v2.7.0** (commit 56fc84e, 2026-05-29)
> Major full-app redesign. Key changes: warm-earth palette (Saffron / Pistachio / Persian Blue / Honey / Pomegranate), three-font stack (Source Serif 4 italic / IBM Plex Sans / IBM Plex Mono), SealMark SVG logo (saffron octagon + serif italic wordmark), CSS custom properties (`--amn-*`) in `global.css`, settings-drawer preset picker simplified to single Amaneh entry.
The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction). The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction).
> [!info] > [!info]

View File

@@ -21,7 +21,7 @@ A drawer-based UI lets the end user toggle visual preferences. Settings persist
| **Contrast** | `default` · `bold` | `default` | localStorage | | **Contrast** | `default` · `bold` | `default` | localStorage |
| **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage | | **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage |
| **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) | | **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) |
| **Color preset** | one of `default`, `purple`, `cyan`, `blue`, `orange`, `red` | `default` | localStorage | | **Color preset** | `amaneh` (warm-earth) — multi-swatch picker removed in v2.7.0 | `amaneh` | localStorage |
| **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage | | **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage |
| **Compact navigation** | boolean | `false` | localStorage | | **Compact navigation** | boolean | `false` | localStorage |
| **Border radius** | 024 | 8 | localStorage | | **Border radius** | 024 | 8 | localStorage |

View File

@@ -2,10 +2,14 @@
title: Theme Configuration title: Theme Configuration
tags: [design-system, theme, mui] tags: [design-system, theme, mui]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Theme Configuration # Theme Configuration
> [!info] Amaneh v2.7.0 (commit 56fc84e)
> The active theme now uses the Amaneh warm-earth palette and the three-font stack (Source Serif 4 / IBM Plex Sans / IBM Plex Mono). MUI component overrides were updated for `Button`, `Card`, `Paper`, `AppBar`, `Chip`, and `Label`. The settings-drawer color-preset swatch picker was simplified to a single Amaneh entry.
The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache. The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache.
--- ---

View File

@@ -2,39 +2,45 @@
title: Typography title: Typography
tags: [design-system, typography, fonts] tags: [design-system, typography, fonts]
created: 2026-05-23 created: 2026-05-23
updated: 2026-05-30
--- ---
# Typography # Typography
The system uses **Public Sans Variable** as the primary face with **Barlow** as a secondary (display) face, plus locale-specific Persian/Arabic faces loaded when the active language requires them. > [!info] Amaneh Design System v2.7.0 (commit 56fc84e)
> The font stack changed in v2.7.0 from Public Sans + Barlow to a **three-font purposeful stack**:
> - **Source Serif 4** — headings in italic; editorial, humanist character
> - **IBM Plex Sans** — body and UI text; technical clarity, RTL-compatible
> - **IBM Plex Mono** — amounts, wallet addresses, tx hashes; monospaced, tabular-nums built-in
The system uses a three-font purposeful stack for the Amaneh design. Locale-specific Persian/Arabic faces are loaded when the active language requires them.
--- ---
## 1. Font stack ## 1. Font stack
Loaded via `@fontsource-variable` (variable fonts streamed at build) plus `@fontsource/barlow`. Confirm in `frontend/package.json`: Loaded via `@fontsource-variable`. Current active fonts (`frontend/package.json`):
```jsonc ```jsonc
"@fontsource-variable/public-sans": "^5.2.5", // Primary "@fontsource-variable/source-serif-4": "...", // Headings (italic)
"@fontsource-variable/dm-sans": "^5.2.5", // Optional preset "@fontsource/ibm-plex-sans": "...", // UI / body
"@fontsource-variable/inter": "^5.2.5", // Optional preset "@fontsource/ibm-plex-mono": "...", // Amounts, addresses, hashes
"@fontsource-variable/nunito-sans": "^5.2.5", // Optional preset
"@fontsource/barlow": "^5.2.5", // Secondary (display)
``` ```
Imported in `frontend/src/app/layout.tsx` (or a fonts module) so Next can fingerprint and preload them. The settings drawer still lists alternative fonts (DM Sans, Inter, Nunito Sans, Public Sans) for user override.
Default font-family stack in the theme: Default font-family stack in the theme:
```css ```css
font-family: "Public Sans Variable", "Helvetica", "Arial", sans-serif; /* Headings */
font-family: "Source Serif 4 Variable", Georgia, serif;
/* UI / body */
font-family: "IBM Plex Sans", "Helvetica", "Arial", sans-serif;
/* Monospaced (amounts / addresses) */
font-family: "IBM Plex Mono", "Courier New", monospace;
``` ```
Display-only headings (banners, hero) may override with Barlow via the `sx` prop: Use `sx={{ fontFamily: 'IBMPlexMono' }}` (theme alias) for any USDT amounts, contract addresses, or transaction hashes.
```tsx
<Typography variant="h1" sx={{ fontFamily: '"Barlow", serif' }}>Welcome</Typography>
```
--- ---

View File

@@ -190,9 +190,9 @@ If you see repeat disputes against the same seller (or repeat frivolous disputes
**Dashboard → Payment → List** shows all payments with filters by status, provider, network, time range. **Dashboard → Payment → List** shows all payments with filters by status, provider, network, time range.
Watch for: Watch for:
- **Stuck payments** (pending > 1h) — SHKeeper webhook may have failed; check logs. - **Stuck payments** (pending > 1h) — Request Network webhook/reconciliation may not have completed; check webhook logs and derived-destination balances.
- **Failed webhooks** — SHKeeper retried but signature didn't verify; see [[Payment API]]. - **Failed webhooks** — Request Network signature verification or payload validation failed; see [[Payment API]] and [[Request Network Integration Constraints]].
- **Missing tx hashes** on completed payments — run the repair script (see §6.3). - **Missing tx hashes** on completed payments — use the payment console or reconciliation job to fetch and verify the on-chain transaction before any release.
### 6.2 Manual payout ### 6.2 Manual payout
@@ -202,18 +202,18 @@ For sellers who can't access self-service or for one-off ops:
2. Fields: recipient address, amount, token (USDT…), network (BSC…), reference, description. 2. Fields: recipient address, amount, token (USDT…), network (BSC…), reference, description.
3. Submit → ts-node script also exists at `backend/manual-payout-test.ts` for local testing. 3. Submit → ts-node script also exists at `backend/manual-payout-test.ts` for local testing.
Behind the scenes this calls SHKeeper's payout endpoint. See [[Payout Flow]]. Behind the scenes this should create a release/refund instruction and ledger entry, then route signing through the configured custody signer. See [[Payout Flow]] and [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
### 6.3 Fix missing transaction hashes ### 6.3 Fix missing transaction hashes
Some completed payments may lack the on-chain tx hash (webhook race, partial confirmation). Run: Some completed payments may lack the on-chain tx hash (webhook race, callback delay, or partial reconciliation). Prefer the admin payment console or Request Network reconciliation tooling. For older SHKeeper records only, use the historical repair script:
```bash ```bash
cd /Users/mojtabaheidari/code/backend cd /Users/mojtabaheidari/code/backend
node fix-transaction-hashes.js node fix-transaction-hashes.js
``` ```
The script polls SHKeeper for each affected invoice and patches `transactionHash` + `blockchain.transactionHash` in MongoDB. The legacy script polls SHKeeper for each affected historical invoice and patches `transactionHash` + `blockchain.transactionHash` in MongoDB. Do not use it for new Request Network payments.
See [[Scripts]] for the full inventory. See [[Scripts]] for the full inventory.

View File

@@ -304,7 +304,7 @@ Bookmark these for instant reference:
- [[Seller Guide]] — common Seller questions - [[Seller Guide]] — common Seller questions
- [[Glossary]] — terminology reference - [[Glossary]] — terminology reference
- [[Authentication Flow]] · [[Password Reset Flow]] · [[Passkey (WebAuthn) Flow]] — how auth actually works - [[Authentication Flow]] · [[Password Reset Flow]] · [[Passkey (WebAuthn) Flow]] — how auth actually works
- [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] — how payments flow - [[Escrow Flow]] · [[Request Network Integration Constraints]] · [[Payout Flow]] — how payments flow
- [[Dispute Flow]] — when refund requests need to go to dispute - [[Dispute Flow]] — when refund requests need to go to dispute
- [[Notification Flow]] — why a user might not have received an email - [[Notification Flow]] — why a user might not have received an email
- [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports - [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports

View File

@@ -72,7 +72,7 @@ Delivery addresses are required before some sellers will accept your offer.
## 3. Connecting a wallet ## 3. Connecting a wallet
If you want to pay via **Web3** instead of SHKeeper invoice: If you want to pay from your own wallet:
1. **Dashboard → Account → Wallet**. 1. **Dashboard → Account → Wallet**.
2. Click **Connect Wallet**. 2. Click **Connect Wallet**.
@@ -81,7 +81,7 @@ If you want to pay via **Web3** instead of SHKeeper invoice:
5. The connected address appears as a chip. You can disconnect anytime. 5. The connected address appears as a chip. You can disconnect anytime.
> [!info] > [!info]
> Connecting a wallet is **optional**. SHKeeper QR payments work without one. See [[Payment Flow - DePay & Web3]]. > Connecting a wallet is required for the in-house Request Network checkout. See [[Escrow Flow]] and [[Request Network Integration Constraints]].
--- ---
@@ -202,32 +202,22 @@ Effects:
## 8. Paying for an order ## 8. Paying for an order
Two payment paths. Pick at the **Pay** step. The current payment path is the Request Network in-house checkout.
### 8.1 Path A — SHKeeper invoice (recommended for non-crypto-native users) ### 8.1 Request Network checkout
1. Click **Pay with crypto invoice**. 1. Click **Pay**.
2. Choose a token + network (e.g., USDT on BSC). 2. Choose a token + network (e.g., USDT on BSC).
3. A QR code + address appears. 3. Connect or select your wallet.
4. Open your wallet (any wallet that supports the network). 4. Approve the token spend if prompted.
5. Scan the QR, send the exact amount, confirm in your wallet. 5. Confirm the payment transaction in your wallet.
6. The page updates in real-time as the blockchain confirms (typically 30s5 min). 6. The page updates in real-time as the blockchain confirms (typically 30s5 min).
7. Status moves to **Funded** when fully confirmed. 7. Status moves to **Funded** when fully confirmed.
> [!warning] > [!warning]
> Send the **exact** amount on the **exact** network. Sending USDT on the wrong network (e.g., ERC-20 instead of BSC) WILL lose your funds. The displayed network is binding. > Send the **exact** amount on the **exact** network. Sending USDT on the wrong network (e.g., ERC-20 instead of BSC) WILL lose your funds. The displayed network is binding.
See [[Payment Flow - SHKeeper]]. See [[Escrow Flow]].
### 8.2 Path B — Direct Web3 wallet
1. Click **Pay from connected wallet** (requires a connected wallet — see §3).
2. Your wallet pops up a transaction approval (token transfer to escrow address).
3. Approve & sign.
4. Wait for on-chain confirmation.
5. Backend verifies the transaction and moves status to **Funded**.
See [[Payment Flow - DePay & Web3]].
--- ---
@@ -405,6 +395,6 @@ Contact support — account deletion is a manual operation by admins to ensure a
## 16. Related ## 16. Related
- [[Seller Guide]] · [[Admin Guide]] · [[Support Guide]] - [[Seller Guide]] · [[Admin Guide]] · [[Support Guide]]
- Flows: [[Authentication Flow]] · [[Registration Flow]] · [[Purchase Request Flow]] · [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] · [[Delivery Confirmation Flow]] · [[Dispute Flow]] · [[Rating Flow]] · [[Referral Flow]] - Flows: [[Authentication Flow]] · [[Registration Flow]] · [[Purchase Request Flow]] · [[Escrow Flow]] · [[Delivery Confirmation Flow]] · [[Dispute Flow]] · [[Rating Flow]] · [[Referral Flow]]
- Models: [[User]] · [[PurchaseRequest]] · [[Payment]] · [[Address]] - Models: [[User]] · [[PurchaseRequest]] · [[Payment]] · [[Address]]
- [[Glossary]] - [[Glossary]]

View File

@@ -190,7 +190,7 @@ Use `src/utils/logger.ts`:
import { log, logError } from "src/utils/logger"; import { log, logError } from "src/utils/logger";
log(`✅ Payment ${id} confirmed`); log(`✅ Payment ${id} confirmed`);
logError("SHKeeper webhook verification failed", err); logError("Request Network webhook verification failed", err);
``` ```
Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs. Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs.

View File

@@ -80,50 +80,59 @@ In dev, Redis runs without a password. In production the compose entrypoint is `
--- ---
## Payments — SHKeeper ## Payments — Request Network
SHKeeper is the crypto payment gateway. See [[Payment Flow]] and [[SHKeeper Integration]] in the architecture section. Request Network is the current primary payment provider. See [[PRD - Request Network In-House Checkout]], [[Request Network Integration Constraints]], and [[Escrow Flow]].
| Name | Repo | Required | Default | Example | Purpose | | Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------| |------|------|----------|---------|---------|---------|
| `SHKEEPER_BASE_URL` | backend | ✅ | — | `https://shkeeper.example.com` | Base API URL | | `REQUEST_NETWORK_ENABLED` | backend | optional | `true` | `true` | Enables `request.network` as an available provider |
| `SHKEEPER_API_URL` | backend | ✅ | — | `https://shkeeper.example.com/api/v1` | Versioned API URL | | `REQUEST_NETWORK_API_KEY` | backend | ✅ | — | `cli_...` | Request Network API credential |
| `SHKEEPER_API_KEY` | backend | ✅ | — | — | `X-Shkeeper-Api-Key` header | | `REQUEST_NETWORK_API_BASE_URL` | backend | ✅ | `https://api.request.network` | `https://api.request.network` | Request Network API base URL |
| `SHKEEPER_WEBHOOK_SECRET` | backend | ✅ | — | — | HMAC secret for inbound webhook signatures | | `REQUEST_NETWORK_ORIGIN` | backend | ✅ | `FRONTEND_URL` | `https://dev.amn.gg` | Origin sent to Request Network API |
| `SHKEEPER_CALLBACK_SECRET` | backend | ✅ | — | — | Older alias for webhook secret; some payloads still use it | | `REQUEST_NETWORK_MERCHANT_REFERENCE` | backend | ✅ | — | `<receiver>@eip155:56#...:<token>` | Encodes receiver, chain, payment reference, and token context |
| `SHKEEPER_ALLOWED_TOKENS` | backend | optional | `USDT,USDC` | `USDT,USDC,BTC` | Comma-separated list of accepted tokens | | `REQUEST_NETWORK_NETWORK` | backend | optional | `bsc` | `bsc` | Default checkout network |
| `SHKEEPER_NETWORKS` | backend | optional | `bsc,polygon` | `bsc,polygon,eth` | Networks enabled in checkout | | `REQUEST_NETWORK_PAYMENT_CURRENCY` | backend | optional | `USDT` | `USDC` | Default checkout token symbol |
| `SHKEEPER_ENVIRONMENT` | backend | optional | `production` | `sandbox` | Switches SHKeeper sandbox vs prod behaviour | | `REQUEST_NETWORK_WEBHOOK_CALLBACK_URL` | backend | ✅ | — | `https://dev.amn.gg/api/payment/request-network/webhook` | Provider callback URL |
| `SHKEEPER_FORCE_PAYOUT_DEMO` | backend | optional | `false` | `true` | Skips real-chain payout; demo-confirms after 5s | | `REQUEST_NETWORK_WEBHOOK_SECRET` | backend | ✅ | — | — | HMAC secret for inbound webhook signatures |
| `SHKEEPER_FORCE_REAL` | backend | optional | `false` | `true` | Forces real-chain even in dev/sandbox | | `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | backend | optional | `false` | `false` | Allows explicit Request Network test webhooks only for controlled smoke tests |
| `ADMIN_PAYOUT_WALLET_ADDRESS` | backend | ✅ for payouts | — | `0xAc23…` | Wallet that receives platform fees / payouts |
| `ESCROW_WALLET_ADDRESS` | backend | ✅ | — | `0xa304…` | Master escrow address used by payments service | | `ESCROW_WALLET_ADDRESS` | backend | ✅ | — | `0xa304…` | Master escrow address used by payments service |
| `RECEIVER_WALLET_ADDRESS` | backend | optional | — | `0x…` | Used by alternative payout flows | | `RECEIVER_WALLET_ADDRESS` | backend | optional | — | `0x…` | Used by alternative payout flows |
### Historical SHKeeper keys
`SHKEEPER_*` variables may still appear in legacy migration docs or old `.env` files. They are not the current primary checkout path and should not be used for new payment work unless a deliberate legacy-record reconciliation task requires them.
--- ---
## Payments — Provider Selection ## Payments — Provider Selection
| Name | Repo | Required | Default | Example | Purpose | | Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------| |------|------|----------|---------|---------|---------|
| `PAYMENT_PROVIDER` | backend | optional | `shkeeper` | `request.network` | Active provider for new payment intents | | `PAYMENT_PROVIDER` | backend | optional | `request.network` | `request.network` | Active provider for new payment intents |
| `PAYMENT_DEFAULT_PROVIDER` | backend | optional | `shkeeper` | `shkeeper` | Fallback alias when `PAYMENT_PROVIDER` is unset | | `PAYMENT_DEFAULT_PROVIDER` | backend | optional | `request.network` | `request.network` | Fallback alias when `PAYMENT_PROVIDER` is unset |
| `PAYMENT_ENABLED_PROVIDERS` | backend | optional | `shkeeper` | `shkeeper,request.network` | Comma-separated providers allowed at runtime | | `PAYMENT_ENABLED_PROVIDERS` | backend | optional | `request.network` | `request.network` | Comma-separated providers allowed at runtime |
| `PAYMENT_ROLLBACK_PROVIDER` | backend | optional | `shkeeper` | `shkeeper` | Provider used when selected provider is not enabled | | `PAYMENT_ROLLBACK_PROVIDER` | backend | optional | `request.network` | `request.network` | Provider used when selected provider is not enabled |
| `PAYMENT_PROVIDER_MODE` | backend | optional | `live` | `dry-run` | Provider mode: `live`, `dry-run`, or `read-only` | | `PAYMENT_PROVIDER_MODE` | backend | optional | `live` | `dry-run` | Provider mode: `live`, `dry-run`, or `read-only` |
| `REQUEST_NETWORK_ENABLED` | backend | optional | `false` | `true` | Adds `request.network` to enabled providers when no explicit list is set | | `REQUEST_NETWORK_ENABLED` | backend | optional | `false` | `true` | Adds `request.network` to enabled providers when no explicit list is set |
| `PAYMENT_REQUEST_NETWORK_COHORT_PERCENT` | backend | optional | `0` | `10` | Percent of new checkout cohort eligible for Request Network | | `PAYMENT_REQUEST_NETWORK_COHORT_PERCENT` | backend | optional | `0` | `10` | Percent of new checkout cohort eligible for Request Network |
| `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | backend | optional | `false` | `false` | Allows `x-request-network-test` webhook bypass only in explicit test mode. Keep false in dev/prod unless running a controlled smoke test. |
| `TRANSACTION_SAFETY_ENABLED` | backend | optional | `true` | `true` | Enables the Transaction Safety Provider gate before Request Network pay-ins are marked completed. |
| `TRANSACTION_SAFETY_REQUIRE_TX_HASH` | backend | optional | `true` | `true` | Blocks completion when provider evidence does not include a transaction hash. |
| `TRANSACTION_SAFETY_REQUIRE_TRANSFER_MATCH` | backend | optional | `true` | `true` | Requires on-chain token/recipient/amount evidence to match the expected payment. |
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | backend | optional | `12` | `12` | Minimum chain confirmations required by the Transaction Safety Provider. |
| `TRANSACTION_SAFETY_AML_PROVIDER` | backend | optional | `none` | `none` | AML/sanctions provider adapter name. Non-`none` values should block until implemented/configured. |
| `PAYMENT_LEDGER_ENFORCEMENT` | backend | optional | `false` | `true` | Enforce ledger gates for release/refund | | `PAYMENT_LEDGER_ENFORCEMENT` | backend | optional | `false` | `true` | Enforce ledger gates for release/refund |
| `PAYMENT_RECONCILIATION_ENABLED` | backend | optional | `false` | `true` | Enable scheduled provider reconciliation jobs | | `PAYMENT_RECONCILIATION_ENABLED` | backend | optional | `false` | `true` | Enable scheduled provider reconciliation jobs |
| `TREZOR_SAFEKEEPING_REQUIRED` | backend | optional | `false` | `true` | Optional hardware-signature gate for release/refund confirmation. Only the literal value `true` enforces Trezor proof. | | `TREZOR_SAFEKEEPING_REQUIRED` | backend | optional | `false` | `true` | Optional hardware-signature gate for release/refund confirmation. Only the literal value `true` enforces Trezor proof. |
--- ---
## Payments — DePay / Web3 (frontend) ## Payments — Wallet UI (frontend)
| Name | Repo | Required | Default | Example | Purpose | | Name | Repo | Required | Default | Example | Purpose |
|------|------|----------|---------|---------|---------| |------|------|----------|---------|---------|---------|
| `NEXT_PUBLIC_DEPAY_INTEGRATION_ID` | frontend | ✅ for DePay | — | `1330e2d3-…` | DePay widget integration ID | | `NEXT_PUBLIC_DEPAY_INTEGRATION_ID` | frontend | legacy only | — | `1330e2d3-…` | Historical DePay widget integration ID |
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | frontend | ✅ | — | `0xa304…` | Escrow address shown to buyers in the wallet flow | | `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | frontend | ✅ | — | `0xa304…` | Escrow address shown to buyers in the wallet flow |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | frontend | ✅ | — | `283b54dd…` | WalletConnect v2 project ID | | `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | frontend | ✅ | — | `283b54dd…` | WalletConnect v2 project ID |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | frontend | ✅ | — | — | Alchemy RPC for mainnet | | `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | frontend | ✅ | — | — | Alchemy RPC for mainnet |
@@ -265,20 +274,23 @@ SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_FROM="AMN <no-reply@amn.gg>" SMTP_FROM="AMN <no-reply@amn.gg>"
# SHKeeper (set when ready) # Payments
PAYMENT_PROVIDER=shkeeper PAYMENT_PROVIDER=request.network
PAYMENT_ENABLED_PROVIDERS=shkeeper PAYMENT_ENABLED_PROVIDERS=request.network
PAYMENT_ROLLBACK_PROVIDER=shkeeper PAYMENT_ROLLBACK_PROVIDER=request.network
REQUEST_NETWORK_ENABLED=false REQUEST_NETWORK_ENABLED=true
PAYMENT_LEDGER_ENFORCEMENT=false REQUEST_NETWORK_API_KEY=
REQUEST_NETWORK_API_BASE_URL=https://api.request.network
REQUEST_NETWORK_ORIGIN=https://dev.amn.gg
REQUEST_NETWORK_MERCHANT_REFERENCE=
REQUEST_NETWORK_NETWORK=bsc
REQUEST_NETWORK_PAYMENT_CURRENCY=USDC
REQUEST_NETWORK_WEBHOOK_CALLBACK_URL=https://dev.amn.gg/api/payment/request-network/webhook
REQUEST_NETWORK_WEBHOOK_SECRET=
REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS=false
PAYMENT_LEDGER_ENFORCEMENT=true
PAYMENT_RECONCILIATION_ENABLED=false PAYMENT_RECONCILIATION_ENABLED=false
TREZOR_SAFEKEEPING_REQUIRED=false TREZOR_SAFEKEEPING_REQUIRED=false
SHKEEPER_BASE_URL=
SHKEEPER_API_URL=
SHKEEPER_API_KEY=
SHKEEPER_WEBHOOK_SECRET=
SHKEEPER_CALLBACK_SECRET=
SHKEEPER_FORCE_PAYOUT_DEMO=true
# OpenAI (optional) # OpenAI (optional)
OPENAI_API_KEY= OPENAI_API_KEY=
@@ -293,8 +305,36 @@ AUTO_SEED_ON_START=true
ESCROW_WALLET_ADDRESS=0xa3049825c0785095EEd5E7976E0E539466c84044 ESCROW_WALLET_ADDRESS=0xa3049825c0785095EEd5E7976E0E539466c84044
ADMIN_PAYOUT_WALLET_ADDRESS= ADMIN_PAYOUT_WALLET_ADDRESS=
# Derived destinations (per-(buyer, sellerOffer) RN ephemeral wallets — Task #7)
# Backend ONLY needs the xpub. The master seed must live in KMS/Trezor.
DERIVED_DESTINATION_XPUB=
# Only set DERIVED_DESTINATION_XPRIV when DERIVED_DESTINATION_SWEEP_SIGNER=hot-key
# (dev shortcut). For prod, leave this blank and use the Trezor flow (Task #11).
DERIVED_DESTINATION_XPRIV=
DERIVED_DESTINATION_BASE_PATH=m/44'/60'/0'
DERIVED_DESTINATION_CHAIN_ID=56
DERIVED_DESTINATION_SWEEP_SIGNER=build-only
DERIVED_DESTINATION_MIN_SWEEP_AMOUNT=0
DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000
DERIVED_DESTINATION_SWEEP_AUTOSTART=true
# Master sweep wallet private key (pays gas for permit() + transferFrom() on non-BSC
# chains; sends BNB gas top-ups on BSC). Should be a dedicated low-balance hot wallet
# — NOT the same key used for escrow release/refund.
SWEEP_MASTER_PRIVKEY=
# BSC gas top-up thresholds (in BNB). If derived address BNB balance is below MIN, top up by TOP_UP.
SWEEP_GAS_MIN_BNB=0.001
SWEEP_GAS_TOP_UP_BNB=0.002
# AMN Pay Scanner (replaces Request Network for pay-in detection)
AMN_SCANNER_URL=
AMN_SCANNER_WEBHOOK_SECRET=
AMN_SCANNER_DEFAULT=false
# OAuth # OAuth
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
``` ```
> [!tip] Generate `JWT_SECRET` deterministically per environment so you don't accidentally invalidate sessions when restarting. Store it in your team's secret manager. > [!tip] Generate `JWT_SECRET` deterministically per environment so you don't accidentally invalidate sessions when restarting. Store it in your team's secret manager.
> [!warning] `DERIVED_DESTINATION_XPRIV` is a development-only shortcut. In production, set `DERIVED_DESTINATION_SWEEP_SIGNER=build-only` and pair with Task #11 Trezor signing so the master seed never sits on the backend host.

View File

@@ -125,7 +125,7 @@ frontend/
│ ├── actions/ # Server-side / shared async API calls (axios) │ ├── actions/ # Server-side / shared async API calls (axios)
│ ├── auth/ # JWT / OAuth / passkey context, guards, hooks, services │ ├── auth/ # JWT / OAuth / passkey context, guards, hooks, services
│ ├── socket/ # Socket.IO client, hooks, components, contexts │ ├── socket/ # Socket.IO client, hooks, components, contexts
│ ├── web3/ # WalletConnect + Alchemy + DePay glue │ ├── web3/ # WalletConnect + Alchemy + Request Network checkout glue
│ ├── routes/ # Static path constants (paths object) │ ├── routes/ # Static path constants (paths object)
│ ├── utils/ # logger, format-number, format-time, localStorage, … │ ├── utils/ # logger, format-number, format-time, localStorage, …
│ ├── types/ # Shared TS types (mirrors backend models where useful) │ ├── types/ # Shared TS types (mirrors backend models where useful)

View File

@@ -122,9 +122,9 @@ You must `docker login git.manko.yoga -u manawenuz` first. Pushes both tags and
### `start-ngrok.sh` ### `start-ngrok.sh`
**Purpose.** Start `ngrok http` against a local port (default `8083`) and print the public URL by polling the inspector at `127.0.0.1:4040`. Lets you receive SHKeeper webhooks on your laptop. **Purpose.** Start `ngrok http` against a local port (default `8083`) and print the public URL by polling the inspector at `127.0.0.1:4040`. Lets you receive Request Network webhooks on your laptop.
**When to run.** Local SHKeeper webhook development. **When to run.** Local Request Network webhook development.
**Example.** **Example.**
@@ -219,9 +219,9 @@ Each script takes a base URL + admin token. Inspect them before running.
### `manual-test.ts` ### `manual-test.ts`
**Purpose.** Local sanity check for the SHKeeper service: calls `createPayInIntent` with mock data and verifies a webhook signature in dev mode. **Purpose.** Historical sanity check for the old SHKeeper service.
**When to run.** Smoke-test after changing SHKeeper code without running the full suite. **When to run.** Legacy-record troubleshooting only; new payment work should use the Request Network tests.
**Example.** **Example.**
@@ -243,13 +243,13 @@ npm run dev &
ts-node manual-payout-test.ts ts-node manual-payout-test.ts
``` ```
> [!warning] Will create a real payout record in the DB. With `SHKEEPER_FORCE_PAYOUT_DEMO=true` no on-chain transaction is sent; without that flag a real on-chain transfer can occur. > [!warning] Will create a real payout record in the DB. Treat this as a legacy/manual helper; routine releases should go through ledger-gated release/refund orchestration.
### `fix-transaction-hashes.js` ### `fix-transaction-hashes.js`
**Purpose.** One-off backfill — walks completed Payments missing `transactionHash`, queries SHKeeper for the original invoice, extracts the confirmed transaction hash, and updates the payment document. **Purpose.** Historical one-off backfill — walks completed legacy Payments missing `transactionHash`, queries SHKeeper for the original invoice, extracts the confirmed transaction hash, and updates the payment document.
**When to run.** Only if you see payments displayed as "completed" with a missing tx hash. Rate-limits itself with a 1s delay per record. **When to run.** Only for old SHKeeper records. New Request Network payments should be reconciled through Request Network webhook/reconciliation tooling.
**Example.** **Example.**
@@ -260,7 +260,7 @@ SHKEEPER_API_KEY=... \
node fix-transaction-hashes.js node fix-transaction-hashes.js
``` ```
> [!warning] Hits the live SHKeeper API and writes to MongoDB. Take a backup ([[Backup & Recovery]]). > [!warning] Hits the live legacy SHKeeper API and writes to MongoDB. Take a backup ([[Backup & Recovery]]).
### `check-templates.js`, `get-admin-token.js` ### `check-templates.js`, `get-admin-token.js`

View File

@@ -47,9 +47,9 @@ Both repos use **Jest** as the unit/integration runner. The frontend additionall
There are also four large aggregate suites referenced in `package.json` (some may live in branches or be reintroduced as the codebase evolves): There are also four large aggregate suites referenced in `package.json` (some may live in branches or be reintroduced as the codebase evolves):
- `models.test.ts` — every Mongoose schema, validation, indexes, relationships - `models.test.ts` — every Mongoose schema, validation, indexes, relationships
- `payment-services.test.ts` — DePay, SHKeeper, Web3, admin operations - `request-network-adapter.test.ts`, `request-network-webhook.test.ts`, `rn-in-house-checkout.test.ts` — Request Network checkout and webhook behavior
- `payment-ledger.service.test.ts`, `payment-release-refund-orchestration.test.ts` — ledger and release/refund behavior
- `complete-backend.test.ts` — Auth, marketplace, chat, notification, address, user, file, email, AI - `complete-backend.test.ts` — Auth, marketplace, chat, notification, address, user, file, email, AI
- `shkeeper-backend.test.ts` — Service layer + API endpoints for SHKeeper
### Commands ### Commands
@@ -61,11 +61,11 @@ npm run test:watch # interactive watch mode
npm run test:coverage # also emit coverage report to ./coverage/ npm run test:coverage # also emit coverage report to ./coverage/
npm run test:all # explicit __tests__/ folder npm run test:all # explicit __tests__/ folder
# Focused suites (each maps to a single file): # Focused suites:
npm run test:models # jest __tests__/models.test.ts npm run test:models # jest __tests__/models.test.ts
npm run test:payment # jest __tests__/payment-services.test.ts
npm run test:complete # jest __tests__/complete-backend.test.ts npm run test:complete # jest __tests__/complete-backend.test.ts
npm run test:shkeeper # jest __tests__/shkeeper-backend.test.ts npm run test -- --testPathPattern=request-network
npm run test -- --testPathPattern=payment-ledger
``` ```
Pass extra Jest flags after `--`: Pass extra Jest flags after `--`:
@@ -101,7 +101,7 @@ describe('GET /api/health', () => {
``` ```
3. Use the in-memory DB — connections are wired in `setup.ts`. Each test starts with a clean collection. 3. Use the in-memory DB — connections are wired in `setup.ts`. Each test starts with a clean collection.
4. Mock outbound HTTP (SHKeeper, OpenAI) with `jest.spyOn(axios, 'post')`. Never hit a real provider from tests. 4. Mock outbound HTTP (Request Network, OpenAI, AML providers) with `jest.spyOn(axios, 'post')` or a dedicated adapter mock. Never hit a real provider from tests.
> [!warning] `maxWorkers: 1` makes tests serial. Don't introduce timing-sensitive parallelism — instead, keep individual tests small and deterministic. > [!warning] `maxWorkers: 1` makes tests serial. Don't introduce timing-sensitive parallelism — instead, keep individual tests small and deterministic.

View File

@@ -0,0 +1,168 @@
---
title: Workflow — Full Codebase Audit and Remediation
tags: [development, audit, security, performance, automation, workflow]
created: 2026-05-30
status: living
---
# Workflow — Full Codebase Audit and Remediation
A periodic, multi-agent health pass over the whole platform. Run it *from time to time*
to keep docs honest, surface security / functionality / performance issues, fix the
obvious ones automatically, and hand the judgement calls back to a human.
It is implemented as a **Claude Code workflow** (deterministic orchestration of many
subagents) and lives at:
```
escrow/.claude/workflows/full-codebase-audit.js
```
Because it is a *named* workflow, it can be launched by name from any session rooted at
`escrow/`:
```
Workflow({ name: 'full-codebase-audit' })
```
This document explains the flow, the design decisions baked into it, how to run it, and
includes the **full source** so it can be recreated from scratch if the file is ever lost.
---
## 1. What it does (the flow)
```
Sync ─▶ Doc Sync ─▶ Audit ─▶ Verify ─▶ Strategy ─▶ Mitigate ─▶ Report
```
| # | Phase | Model | What happens |
|---|-------|-------|--------------|
| 1 | **Sync** | Sonnet | `git fetch` all 4 repos; `git pull --ff-only` only when the tree is clean. Never touches uncommitted work. |
| 2 | **Doc Sync** | Sonnet | One agent per repo updates docs to match recent code changes. **scanner** gets a heavy doc-generation mandate (it is the least mature project and has zero markdown docs). |
| 3 | **Audit** | Sonnet | Fan-out of `repo × dimension` agents (security / functionality / performance / supply-chain) producing structured findings. |
| 4 | **Verify** | Sonnet | Each finding is adversarially re-checked against the code to kill false positives. Pipelined with Audit — a finding verifies as soon as its slice is found. |
| 5 | **Strategy** | **Opus** | The lead-architect agent clusters findings into systemic themes and splits them into a **no-brainer** queue (safe to auto-fix) and a **decision** queue (needs human judgement). |
| 6 | **Mitigate** | Sonnet | Applies the no-brainers, grouped one agent per repo. **Working-tree only — no commit, no push** (see §2). |
| 7 | **Report** | Sonnet | Writes the audit report under `09 - Audits/`, creates `ISSUE-###` files for the decision queue + any skipped fix, and updates the audit index. |
The workflow **returns** a `decisionQueue` to the calling assistant. Workflows run in the
background and cannot prompt interactively, so the assistant presents that queue to you
with `AskUserQuestion` — that is the "allow the user to decide about the non-critical /
non-trivial ones" step.
---
## 2. Design decisions baked in
These are the defaults; each is overridable via `args` (§4).
- **Workers are Sonnet, design/decision is Opus.** Cheap, parallel grunt work (syncing,
doc-writing, finding, verifying, applying fixes, scribing) runs on `sonnet`. The two
jobs that need judgement — strategy/triage — run on `opus`.
- **Fixes are working-tree only.** The Mitigate phase applies changes but never
`git add/commit/push`. Rationale: the repos are frequently dirty and a parallel agent
(`moojttaba`) pushes to the same branches, so auto-committing risks collisions and
mixing unrelated work. You review the diff, then commit yourself.
- **Pull is fetch + ff-only, skip-if-dirty.** The Sync phase never stashes or merges over
uncommitted changes; on a dirty tree it just reports behind/ahead counts.
- **Conservative triage.** When in doubt, a finding goes to the *decision* queue, not the
*no-brainer* queue. Anything that changes business logic, data shape, or could break
callers is never auto-applied.
- **scanner is the doc priority.** It is the youngest service with no docs, so Doc Sync
spends its biggest effort generating architecture / API / flow / ops docs for it in the
nick-doc vault plus a `README.md` in the repo.
---
## 3. How to run it
From a Claude Code session whose working directory is `escrow/`:
1. **Trigger** — ask Claude to run the `full-codebase-audit` workflow (the word
"workflow" opts into multi-agent orchestration), or it can be invoked directly:
`Workflow({ name: 'full-codebase-audit' })`.
2. **Watch**`/workflows` shows the live phase tree.
3. **Decide** — when it finishes, Claude reads the returned `decisionQueue` and asks you
about each non-trivial item via `AskUserQuestion`. Approved items become a follow-up
change set; the rest stay as `ISSUE-###` files.
4. **Review & commit** — inspect `git diff` in each repo for the auto-applied no-brainers,
then commit them yourself.
It is **expensive** (dozens of agents across 4 repos). Run it periodically, not on every
change.
---
## 4. Overriding behaviour (`args`)
Pass an `args` object to scope or change the run:
```js
Workflow({ name: 'full-codebase-audit', args: {
repos: ['backend', 'scanner'], // subset; default = all 4
fixMode: 'working-tree', // | 'commit' | 'commit-push'
pullMode: 'fetch-ff-skip-dirty', // | 'stash-pull' | 'hard'
dryRun: false, // true => audit + report only, zero fixes
date: '2026-05-30', // optional; agents otherwise read `date +%F`
}})
```
- `dryRun: true` is the safest way to get a fresh audit + report without any file changes.
- `fixMode: 'commit'` / `'commit-push'` only if you accept the collision risk on shared
branches.
---
## 5. Outputs
- **Docs** — updated/created markdown across repos and the nick-doc vault (scanner-heavy).
- **Audit report** — `nick-doc/09 - Audits/Full Codebase Audit - <date>.md`.
- **Issues** — `nick-doc/Issues/ISSUE-###-*.md` for every decision item and skipped fix,
in the existing issue frontmatter format.
- **Working-tree fixes** — uncommitted no-brainer remediations in each repo.
- **Return value** — `{ summary, systemicThemes, decisionQueue, mitigation, docSync,
report }`, consumed by the assistant to drive the `AskUserQuestion` step.
---
## 6. Recreating the workflow from scratch
If `escrow/.claude/workflows/full-codebase-audit.js` is ever lost, recreate it with the
source below (it is the complete, self-contained script). Save it to that path and it is
runnable again by name.
```js
export const meta = {
name: 'full-codebase-audit',
description: 'Sync repos, refresh docs, audit (security/logic/perf), strategize, auto-fix no-brainers, queue the rest for the user',
whenToUse: 'Periodic full-system health pass across frontend/backend/nick-doc/scanner. Run from time to time.',
phases: [
{ title: 'Sync', detail: 'fetch + ff-only pull (skip if dirty) across all 4 repos' },
{ title: 'Doc Sync', detail: 'update docs from recent code changes; scanner gets heavy doc generation', model: 'sonnet' },
{ title: 'Audit', detail: 'security / functionality / performance / supply-chain findings per repo', model: 'sonnet' },
{ title: 'Verify', detail: 'adversarial verification of each finding', model: 'sonnet' },
{ title: 'Strategy', detail: 'design remediation + triage no-brainer vs needs-user-decision', model: 'opus' },
{ title: 'Mitigate', detail: 'apply no-brainer fixes to the working tree only', model: 'sonnet' },
{ title: 'Report', detail: 'write audit report, ISSUE files, audit index, export doc', model: 'sonnet' },
],
}
// See escrow/.claude/workflows/full-codebase-audit.js for the full body.
// The body is reproduced verbatim there; this guide and that file must stay in sync.
```
> The authoritative, always-current source is the file itself
> (`escrow/.claude/workflows/full-codebase-audit.js`). Treat this section as the recovery
> pointer; if you change the workflow, update the file and bump this doc's notes.
---
## 7. Maintenance notes
- Keep `meta.phases` titles identical to the `phase('…')` calls — they are matched by
string to group progress.
- `Date.now()` / `Math.random()` are unavailable inside workflow scripts; the Report phase
reads the date via `date +%F` from a Bash call instead.
- The dedup key is `repo::file::title-prefix`; widen it if you see near-duplicate findings.
- If false positives creep in, raise the Verify bar (it already drops `confidence: 'low'`).

View File

@@ -9,6 +9,9 @@ created: 2026-05-24
These runbooks cover the selected backend/funds architecture defined in These runbooks cover the selected backend/funds architecture defined in
[[Backend Core Stack Decision Record - 2026-05-24]]. [[Backend Core Stack Decision Record - 2026-05-24]].
> [!note] Historical migration context
> Sections that mention keeping SHKeeper active describe the migration period from the old payment rail to Request Network. Current new-payment operations should use [[Escrow Flow]], [[Request Network Integration Constraints]], and [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## 1. Migration runbook (legacy + provider migration) ## 1. Migration runbook (legacy + provider migration)
### 1.1 Preflight ### 1.1 Preflight

View File

@@ -0,0 +1,260 @@
# Gatus Monitoring — Proposed Config
**Status:** Backend endpoint shipped in 2.6.49 (`backend@6c01a30`). Gatus config ready for deployment. Frontend `/api/health` proxy and ops deployment still pending.
**Owner:** nick + claude
**Author date:** 2026-05-28
**Related:** [[Handoff - Request Network In-House Checkout - 2026-05-28]], memory entries `woodpecker_silent_build_fail` and `feedback-json-assets-copy-to-dist`.
---
## Why
On 2026-05-28 dev.amn.gg silently regressed: every BSC checkout returned `unsupported_chain:56` for hours before a user reported it. The cause was a build-pipeline bug ([[woodpecker_silent_build_fail]]) compounded by an in-process empty chain registry that the backend served happily because the load failure was swallowed by `console.error`.
The CI typecheck gate added in backend commit `28b17f2` closes the build side. The runtime side — *is the deployed thing currently healthy?* — is Gatus's job. A Gatus probe hitting the registry endpoint would have paged within 60 seconds of today's regression, instead of waiting for a user to notice.
Gatus also catches drift that CI cannot:
- A configuration file edited live on the server.
- A dependency that quietly fails to load on container restart.
- A database connection that drops mid-day.
- Stale upstream IPs after [[devEscrow_nginx_after_redeploy]].
---
## What we should monitor
Each endpoint serves one of three purposes: **liveness** (is the container up?), **structural invariants** (is the data the container needs actually loaded?), **integration health** (can it reach the things it depends on?).
### Backend — dev.amn.gg
| Endpoint | Purpose | Interval | Condition |
|---|---|---|---|
| `GET /api/version` | Liveness | 60s | `[STATUS] == 200`, `[BODY].version != ""` |
| `GET /api/admin/rn/networks` (auth-gated) | Chain registry not empty | 60s | `[STATUS] == 200`, `[BODY].chains[0].chainId != null` |
| `GET /api/health` (does not exist yet — see "Required backend work" below) | DB + Redis + registry health all in one | 30s | `[STATUS] == 200`, `[BODY].status == "ok"` |
The most valuable probe is the chain-registry one. The current `/api/admin/rn/networks` requires admin auth, so either:
1. Gatus posts an admin token per request (cheapest now, leaks an admin token into the monitoring config).
2. We add a `GET /api/health` endpoint that exposes a *subset* of invariants (counts only, no addresses) without auth. **Recommended.**
### Backend — prod.amn.gg
Identical probes, separate Gatus group so dev incidents don't drown out prod ones.
### Frontend — dev.amn.gg / prod.amn.gg
| Endpoint | Purpose | Interval | Condition |
|---|---|---|---|
| `GET /` | Page renders | 60s | `[STATUS] == 200`, `[RESPONSE_TIME] < 3000ms` |
| `GET /api/health` (Next.js route, proxy to backend) | End-to-end reachability | 60s | `[STATUS] == 200` |
### External dependencies
| Endpoint | Purpose | Interval | Condition |
|---|---|---|---|
| `https://api.request.network/...` | RN API reachable | 5m | `[STATUS] in (200, 401)` (401 is fine — means it answered) |
| `https://public.chainalysis.com/api/v1/address/...` | AML provider reachable | 5m | `[STATUS] in (200, 404)` |
| `https://bsc-rpc.publicnode.com` (eth_chainId) | RPC liveness | 2m | `[BODY].result == "0x38"` |
If RN's API goes down, in-house checkout still works (we already have the cached intent), but new payment creation fails. Gatus catching this lets us flip to the hosted-page fallback proactively.
---
## Required backend work (before Gatus can be useful)
The `GET /api/health` endpoint was shipped in backend 2.6.49. It is public, rate-limited-skipped, and returns the structured check object below.
**Shape of the endpoint:**
```ts
// GET /api/health (public, rate-limited but not auth-gated)
{
"status": "ok" | "degraded" | "down",
"version": "2.6.48",
"uptimeSec": 12345,
"checks": {
"db": { "ok": true, "latencyMs": 4 },
"redis": { "ok": true, "latencyMs": 1 },
"rnChainRegistry": { "ok": true, "chainCount": 5 },
"rnTokenRegistry": { "ok": true, "tokenCount": 10 },
"rnApi": { "ok": true, "latencyMs": 134 }
}
}
```
Each `checks.*.ok` must reflect the actual current state, not a cached one. If any check fails, `status` flips to `degraded`. If `db.ok === false`, `status` flips to `down`.
**Why this shape rather than per-check endpoints:**
- One probe, all invariants — cheaper for Gatus and clearer in the dashboard.
- The structure lets us add invariants later (e.g. `walletMonitor.ok`, `paymentRedisService.queueDepth`) without changing the URL.
- Public exposure of counts (not addresses, not balances) is low-risk.
**Backend work:** ✅ Complete (2.6.49). Includes `healthCheckService` with 5 checks, route wired in `app.ts`, rate-limiter + logging skip, and 5 route-level unit tests.
---
## Proposed Gatus config
Once `/api/health` exists, the config is straightforward. Drop this into wherever the homelab Gatus instance reads its config from. Adjust group names and Slack/Telegram webhook references to whatever the existing Gatus setup uses for other services.
```yaml
# gatus.amanat.yaml
#
# Amanat escrow monitoring. Three groups: backend (dev + prod), frontend
# (dev + prod), external (RN + Chainalysis + RPCs).
#
# Alerting: piggyback the existing CI Telegram channel so notifications
# show up next to the same channel where deploy notifications already go.
alerting:
telegram:
token: "${TG_TOKEN}"
id: "${TG_GATUS_CHAT_ID}"
default-alert:
enabled: true
send-on-resolved: true
failure-threshold: 3 # 3 consecutive failures before paging
success-threshold: 2
endpoints:
# ── Backend (dev) ───────────────────────────────────────────────────
- name: backend-dev-version
group: backend-dev
url: https://dev.amn.gg/api/version
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].version != \"\""
alerts:
- type: telegram
- name: backend-dev-health
group: backend-dev
url: https://dev.amn.gg/api/health
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == ok"
- "[BODY].checks.db.ok == true"
- "[BODY].checks.redis.ok == true"
- "[BODY].checks.rnChainRegistry.ok == true"
- "[BODY].checks.rnChainRegistry.chainCount >= 1"
- "[BODY].checks.rnTokenRegistry.ok == true"
- "[BODY].checks.rnTokenRegistry.tokenCount >= 1"
alerts:
- type: telegram
# ── Backend (prod) ──────────────────────────────────────────────────
- name: backend-prod-version
group: backend-prod
url: https://amn.gg/api/version
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].version != \"\""
alerts:
- type: telegram
failure-threshold: 2 # tighter on prod
- name: backend-prod-health
group: backend-prod
url: https://amn.gg/api/health
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == ok"
- "[BODY].checks.db.ok == true"
- "[BODY].checks.redis.ok == true"
- "[BODY].checks.rnChainRegistry.chainCount >= 1"
- "[BODY].checks.rnTokenRegistry.tokenCount >= 1"
alerts:
- type: telegram
failure-threshold: 2
# ── Frontend ────────────────────────────────────────────────────────
- name: frontend-dev
group: frontend
url: https://dev.amn.gg/
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 3000"
alerts:
- type: telegram
- name: frontend-prod
group: frontend
url: https://amn.gg/
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 3000"
alerts:
- type: telegram
failure-threshold: 2
# ── External dependencies ───────────────────────────────────────────
- name: rn-api-reachable
group: external
url: https://api.request.network/v2/health
interval: 5m
conditions:
- "[STATUS] in (200, 401, 404)" # any answer = up
alerts:
- type: telegram
- name: chainalysis-public-api
group: external
url: https://public.chainalysis.com/api/v1/address/0x0000000000000000000000000000000000000000
interval: 5m
conditions:
- "[STATUS] in (200, 404)"
alerts:
- type: telegram
- name: bsc-rpc-publicnode
group: external
method: POST
url: https://bsc-rpc.publicnode.com
headers:
Content-Type: application/json
body: '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
interval: 2m
conditions:
- "[STATUS] == 200"
- "[BODY].result == \"0x38\""
alerts:
- type: telegram
```
### Notes on the config
- **`failure-threshold: 3` on dev, `2` on prod** — dev is allowed to flap once before paging; prod pages faster.
- **Telegram alerts only**, matching the rest of the CI/ops channel. If a separate ops channel is wanted, give Gatus its own bot + chat ID.
- **External-dependency probes use response-code ranges**, not strict 200s — RN's API answering with 401 still means it's reachable; what we care about is "is the upstream alive."
---
## Required follow-up issues
| # | What | Where | Status |
|---|------|-------|--------|
| 1 | Backend: add `GET /api/health` endpoint exposing the structured check object | `backend` (escrow-backend) | ✅ Shipped in 2.6.49 |
| 2 | Frontend: add `/api/health` Next.js route that fetches backend health and surfaces it (optional, for end-to-end check) | `frontend` (escrow-frontend) | ⏳ Pending |
| 3 | Ops: deploy Gatus config to the homelab Gatus instance, wire to Telegram | `deployment` repo (`deployment/gatus/config.yaml`) | ✅ Config committed; needs `docker-compose up -d gatus` on server |
| 4 | Ops: document the runbook for each alert (what to check when "backend-dev-health" fires) | this doc, or a sibling runbook file | ⏳ Pending |
---
## What this would have caught (incident retrospective)
| Incident | Probe that would have fired | How long until alert |
|---|---|---|
| 2026-05-28 BSC `unsupported_chain:56` | `backend-dev-health.checks.rnChainRegistry.chainCount >= 1` | ~90s (3× 30s probes) |
| Stale image after silent-build-fail (Tasks #9/#10) | `backend-dev-version.[BODY].version` not matching expected post-deploy version | ~3 min |
| [[devEscrow_nginx_after_redeploy]] (stale upstream 502s) | `backend-dev-version` returning 502 | ~3 min |
| Mongo password rotation breaking connections | `backend-dev-health.checks.db.ok` | ~90s |
| RN API outage on payment-intent creation | `rn-api-reachable` 5m × 3 failures = ~15 min — slower but acceptable for an upstream we don't control |
Net: every major silent-mode incident in this project would now have an alert. The cost is one new endpoint, one Gatus config file, and one Telegram chat ID.

View File

@@ -0,0 +1,94 @@
# Handoff: RN Multichain Proxy Probe — 2026-05-28
## Probe execution
Script: `backend/scripts/probe-rn-chains.ts` (commits `01b9ea0``4a85737`, backend 2.6.46 → 2.6.47)
Executed: 2026-05-28T15:57:33Z (final run after Base fix)
## Results summary
| Chain | Chain ID | Proxy Address | RPC | Reachable | Has Code | Call Valid | Status |
|-------|----------|---------------|-----|-----------|----------|------------|--------|
| BNB Smart Chain | 56 | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | publicnode | ✅ | ✅ | ✅ | **Verified** |
| Arbitrum One | 42161 | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | publicnode | ✅ | ✅ | ✅ | **Verified** |
| Ethereum Mainnet | 1 | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | publicnode | ✅ | ✅ | ✅ | **Verified** |
| Polygon | 137 | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | publicnode | ✅ | ✅ | ✅ | **Verified** |
| Base | 8453 | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | publicnode | ✅ | ✅ | ✅ | **Verified** |
## Methodology
1. **Reachability**: `eth_blockNumber` against each chain's public RPC endpoint.
2. **Code presence**: `eth_getCode` at the candidate proxy address.
3. **Function validity**: `eth_call` with a dummy `transferFromWithReferenceAndFee` payload. A valid proxy reverts with an ERC-20/execution error (proving the selector is recognized). An invalid address would revert with "unknown function selector" or return empty.
## Key findings
- **BSC / Arbitrum / Polygon**: Canonical CREATE2 proxy `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` is deployed and responds correctly. ✅
- **Ethereum Mainnet**: v0.1.0 proxy `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` is the official deployment per RN smart-contracts artifact. The canonical CREATE2 address is also deployed (v0.2.0-next) but the artifact marks it as an additional deployment. ✅
- **Base**: The canonical CREATE2 address has **no code** on Base. However, RN **does** support Base via a non-canonical deployment at `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` (per RN smart-contracts artifact v0.2.0, `packages/smart-contracts/src/lib/artifacts/ERC20FeeProxy/index.ts`). ✅
## Base proxy address hunt
Source: https://raw.githubusercontent.com/RequestNetwork/requestNetwork/master/packages/smart-contracts/src/lib/artifacts/ERC20FeeProxy/index.ts
```ts
base: {
address: '0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814',
creationBlockNumber: 10827274,
},
```
## Action taken
- Updated `supportedChains.json` with correct per-chain proxy addresses.
- Restored Base USDC/USDT entries to `tokens.json`.
- All 5 chains now active in the registry.
## AML scope note (for legal / compliance review)
The current AML implementation (Task #10, shipped in backend/frontend 2.6.47) performs **sanctions-only screening** via the Chainalysis Public Sanctions API. It checks whether a buyer's source wallet address appears on known sanctions lists (OFAC, UN, HMT, etc.). It does **not** perform full AML risk scoring — there is no transaction clustering, entity attribution, travel-rule monitoring, or behavioral risk scoring. Upgrading to comprehensive AML/KYT would require a paid Chainalysis KYT tier (or equivalent provider such as Elliptic, TRM Labs, or ComplyAdvantage), which runs ~$100K+/year for production volumes and requires an enterprise contract. The sanctions-only tier is free (5,000 requests per 5 minutes) and is the correct scope for a v1 compliance posture, but it should be explicitly described to regulators/customers as "sanctions screening" rather than "AML screening."
## Remaining work
- [ ] BSC USDT paid end-to-end probe (PRD §2 AC #3) — **pending human-in-the-loop**.
- [x] Mainnet USDT `approve(0)` reset verification (PRD §2 AC #4) — **VERIFIED via anvil fork test**.
## Human-blocked items (requires owner with wallet on dev)
These three items cannot be validated by automated tests alone. A human with a funded wallet on the dev environment must execute each probe before the corresponding feature is considered production-ready.
| # | Item | Precise next step | Blocking |
|---|---|---|---|
| 1 | **Task #7C — Live multi-seller divergent-destination probe** | Create a cart with seller-offers from ≥2 different sellers, complete checkout, verify RN creates 2 separate Payments with 2 distinct derived destination addresses, and both webhooks fire correctly. | Task #7 closure |
| 2 | **Task #8 — BSC USDT paid end-to-end probe** | On dev.amn.gg, complete a real BSC USDT pay-in through the in-house checkout (approve + `transferFromWithReferenceAndFee`), confirm webhook marks Payment `completed`, and BscScan shows the token transfer. | Multichain release gate |
| 3 | **Task #11 — Trezor signing dry-run** | Register a physical Trezor via `/api/trezor/register`, build a sweep tx via `POST /api/admin/actions/build-tx`, sign it on-device through the admin UI, broadcast via wagmi, and confirm `POST /api/admin/actions/confirm-tx` accepts the Trezor proof. | Trezor enforcement toggle |
## Mainnet USDT approve(0) reset — fork test verification
**Test:** `scripts/tenderly-usdt-reset-test.sh` (anvil fork of Ethereum mainnet)
**Date:** 2026-05-28
### Setup
- Forked mainnet at `https://ethereum-rpc.publicnode.com`
- Impersonated whale `0xF977814e90dA44bFA03b6295A0616a897441aceC`
- Transferred 100 USDT to test buyer `0x0000…dEaD`
### Transaction sequence
| Step | Description | Tx Hash |
|------|-------------|---------|
| 1 | Transfer USDT whale → buyer | `0x574440bc7aa2915ff8b5adddc9b083420c5e426894fe98d7c72196f7d8e37c22` |
| 2 | `approve(proxy, 50 USDT)` — quirk setup | `0x4dfef21e19e9fe17e7fb60ecefdce6f5a240e74de904f772c57ad3a3013454b8` |
| 3 | `approve(proxy, 0)` — reset | `0xf249a05db18753abf7625b44074b81939764c5a1590928d29aba0757ae5446b0` |
| 4 | `approve(proxy, 100 USDT)` — re-approve | `0xd27c1c382a20db754dd3d67efbb565016ad33c460be9fb7a09986589626aaef5` |
| 5 | `transferFromWithReferenceAndFee` — pay | `0xf7b44000fc11bcc1e832360acc9cbbfccd8be4c21f5940df00115ea7f2c4038a` |
### Results
- Allowance after partial approve: `50,000,000`
- Allowance after reset: `0`
- Allowance after full approve: `100,000,000`
- Buyer balance decreased by exactly 100 USDT after payment ✅
### Verdict
**PRD §2 AC #4 status: VERIFIED** — The USDT-mainnet approve(0) reset flow executes correctly on a mainnet fork. All five transactions succeeded. The two-step approve pattern (reset → re-approve → pay) is validated end-to-end.

View File

@@ -0,0 +1,212 @@
---
title: Handoff - Request Network Confirmation Repair - 2026-05-28
tags: [handoff, operations, payments, request-network, webhook]
created: 2026-05-28
---
# Handoff - Request Network Confirmation Repair - 2026-05-28
## Scope
This handoff covers the Request Network dev payment probe where the buyer callback stayed stuck on "processing payment", plus the local confirmation repair work and the documentation/roadmap updates that followed.
Primary user-reported issue:
- A real BSC Request Network test payment completed on-chain, but Amanat never showed confirmation on `https://dev.amn.gg/payment/callback/?paymentId=6a17e08f1485c1de0ff3cd15`.
## Current Answer
Do **not** run another paid payment test against dev until the local `2.6.26` changes are deployed and the webhook smoke test passes against the deployed stack.
After deploy:
- The original webhook `404` correlation bug should be fixed.
- If Request Network includes a transaction hash and the safety checks pass, the payment should complete.
- If Request Network omits the transaction hash, the webhook should be captured but the payment will remain `transactionSafety.pending` instead of being falsely credited.
## Repositories Touched
Backend:
- `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts`
- `backend/src/services/payment/requestNetwork/signature.ts`
- `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
- `backend/src/services/payment/reconciliation/requestNetworkReconciliationService.ts`
- `backend/src/services/payment/decentralizedPaymentService.ts`
- `backend/src/services/payment/safety/transactionSafetyProvider.ts`
- `backend/src/models/Payment.ts`
- `backend/scripts/smoke/rn-webhook.sh`
- Request Network webhook/reconciliation tests
- `backend/.env.example`
- `backend/package.json`, `backend/package-lock.json`
Frontend:
- `frontend/src/app/payment/callback/page.tsx`
- Frontend version/env files and Dockerfile
Deployment:
- `deployment/docker-compose.yml`
Docs / Taskmaster:
- `nick-doc/PRD - Request Network In-House Checkout.md`
- `nick-doc/.taskmaster/docs/prd-request-network-in-house-checkout.md`
- `nick-doc/01 - Architecture/Request Network Integration Constraints.md`
- `nick-doc/PRD - Request Network Migration and Funds Management.md`
- `nick-doc/07 - Development/Environment Variables.md`
- `nick-doc/02 - Data Models/Payment.md`
- `nick-doc/08 - Operations/Incident Response.md`
- `nick-doc/08 - Operations/Monitoring.md`
- `nick-doc/README.md`
- Taskmaster subtask `3.13` for durable RN webhook ingress and transaction safety
- Taskmaster subtask `6.1` for deploying confirmation repair before the next paid probe
## Evidence From Dev
Test transaction:
```text
0x3a23febd9abd43d7e0851c1ea86c4ceaf08c11098852cb0425fa074e9c88350b
```
Payment document:
```text
paymentId: 6a17e08f1485c1de0ff3cd15
providerPaymentId: rq-af2d092e18cb41bb39ce4b0c
metadata.requestNetworkRequestId: 011ae38f7b99ef135514b987c9629b520b08e7a740f60d92d682f2f06466993a3f
metadata.requestNetworkPaymentReference: rq-af2d092e18cb41bb39ce4b0c
status before repair: pending
```
Nginx/backend evidence:
```text
POST /api/payment/request-network/webhook -> 404
source IP: 34.34.233.192
observed deliveries: four retries on 2026-05-28
```
Conclusion:
- Request Network did call Amanat.
- The payment succeeded on-chain.
- Amanat failed local confirmation because the webhook handler looked up the wrong reference shape and returned `404`.
- The frontend then kept polling too aggressively and eventually hit `429`.
## Implemented Locally
### Backend confirmation repair
- Webhook lookup now searches all known Request Network correlation keys:
- `providerPaymentId`
- `metadata.requestNetworkRequestId`
- `metadata.requestNetworkPaymentReference`
- nested `metadata.requestNetworkData.requestId`
- nested `metadata.requestNetworkData.paymentReference`
- Test webhook bypass is no longer enabled by default.
- New `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` env flag controls explicit test-mode acceptance.
- Request Network adapter uses the same test-mode rule.
### Transaction Safety Provider
Added `TransactionSafetyProvider` as the gate between provider event and escrow credit.
Initial checks:
- transaction hash required by default,
- minimum confirmations required by default,
- transfer recipient/token/amount match required by default,
- AML provider placeholder defaults to `none`; non-`none` values block until implemented.
Webhook and reconciliation completion paths both run through the same safety gate.
### Frontend callback repair
- Callback page now unwraps the backend `{ data: { payment } }` shape.
- Socket events handle both `requestId` and `purchaseRequestId`.
- Polling backs off from 3 seconds to 10 seconds.
- Polling stops after terminal states.
- `429`, `401`, and `403` no longer trap the page in misleading behavior.
- Dashboard redirect paths were corrected.
### Deployment/env
New env vars added to backend/deployment docs:
```text
REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS=false
TRANSACTION_SAFETY_ENABLED=true
TRANSACTION_SAFETY_REQUIRE_TX_HASH=true
TRANSACTION_SAFETY_REQUIRE_TRANSFER_MATCH=true
TRANSACTION_SAFETY_MIN_CONFIRMATIONS=12
TRANSACTION_SAFETY_AML_PROVIDER=none
```
Versions were bumped together:
```text
frontend: 2.6.26
backend: 2.6.26
```
## Verification Already Run
Backend:
```bash
npm test -- __tests__/request-network-webhook.test.ts __tests__/request-network-adapter.test.ts __tests__/payment-reconciliation.service.test.ts --runInBand
npm run typecheck
REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS=true BASE_URL=https://dev.amn.gg ./scripts/smoke/rn-webhook.sh
git diff --check
```
Frontend:
```bash
npx eslint src/app/payment/callback/page.tsx
npx tsc --noEmit -p tsconfig.json
git diff --check
```
Deployment/docs:
```bash
git diff --check
```
Important note: the smoke test against `dev.amn.gg` used `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS=true` because the currently deployed dev stack is still old and unsafe. After deploy, rerun without that override and expect unsigned/test callbacks to be rejected.
## Deploy Gate
Before another paid payment:
1. Commit/push/deploy backend, frontend, and deployment changes.
2. Set the new env vars in Arcane/dev deployment.
3. Confirm backend and frontend report `2.6.26`.
4. Run the RN webhook smoke test against dev without test bypass.
5. Tail nginx and backend logs during the next probe.
6. Inspect `Payment.metadata.transactionSafety` if the callback still waits.
## Recommended Next Work
1. Deploy and verify the confirmation repair.
2. Repeat one small dev BSC payment.
3. If it lands in `transactionSafety.pending` due missing transaction hash, add Request Network status/search enrichment so safety can resolve the tx hash.
4. Build the Cloudflare Worker durable webhook ingress:
- receive raw RN payload and headers,
- durably store delivery evidence,
- forward to backend,
- replay by delivery id/time window/payment reference.
5. Pick the first AML/sanctions provider and wire it behind `TRANSACTION_SAFETY_AML_PROVIDER`.
## Operational Rule
For Request Network incidents:
- Real provider webhook returning `404`: stop paid testing; fix correlation/config.
- Webhook returning `202` with `transactionSafety.pending`: evidence was captured, but payment is not safe to credit yet.
- Webhook returning `200`/completed with safety approved: proceed to normal marketplace state checks.

View File

@@ -0,0 +1,234 @@
# Handoff: Request Network In-House Checkout — 2026-05-28
Status: **fully end-to-end working on dev.amn.gg as of 2.6.38 backend / 2.6.41 frontend**. A 0.01 USDC payment (tx `0x494c77a2…`) flowed: page render → wallet connect (Rabby/injected) → approve → `transferFromWithReferenceAndFee` → RN webhook → backend marks completed → page flips to "پرداخت تأیید شد ✓" → continue → `/dashboard/payment/<id>`.
## What's live
- **Backend 2.6.38** — `/api/payment/request-network/intents` returns an `inHouseCheckout` block (destination, tokenAddress, decimals, chainId, proxyAddress, paymentReference 8-byte hex, feeAmount, feeAddress, amountWei). `GET /api/payment/request-network/:paymentId/checkout` rehydrates the block for an existing Payment record (lazy-enriches legacy records that pre-date 2.6.34 by calling RN's `GET /v2/request/:id`). Public `GET /api/version` for the version badge. `PaymentCoordinator.updatePurchaseRequestStatus` guards both `template-checkout-` and `template-tc-` prefixes (plus regex fallback for any non-ObjectId) — earlier the `template-tc-` blindspot crashed webhook processing on template-checkout payments and stranded escrow.
- **Frontend 2.6.41** — `/checkout/request-network/[paymentId]` page with wagmi state machine: connect → switch-chain → check-allowance → approve → pay → wait-for-webhook. Destination + payment-reference + approve-tx + pay-tx hashes are copyable and click through to BscScan. Once a pay tx is in flight the page no longer reverts to "approve" even though the proxy call consumed the allowance. A 10-second `GET /api/payment/:id` poll runs as a fallback when the socket misses `payment-update`. Success-state continue button handles synthetic purchaseRequestId prefixes (`template-checkout-`, `template-tc-`) by routing to `/dashboard/payment/<id>` instead of the 404-prone `/dashboard/request/<syntheticId>`. WagmiProvider is now rendered unconditionally + the checkout page also self-wraps in its own WagmiProvider for defensive isolation.
Verify which versions are running by hovering the version chip at bottom-left of any page on dev.amn.gg, or `curl https://dev.amn.gg/api/version`.
## Where things stand
A real 0.01 USDC payment ran clean through the in-house path on 2026-05-28. Webhook delivery is durable enough for dev usage; durability for prod is Phase 5 (Cloudflare Worker ingress, not started). Five follow-up tasks were scoped immediately after — see `PRD - Wallet, Multichain, Confirmations, AML, Trezor.md` and Taskmaster `#7..#11`.
## Known issues / open work
- **TypeScript-error CI false-success**: pipelines #40 and #41 reported ✅ green in Woodpecker while `yarn build` was actually failing at the TS step and no image was pushed. Memory entry: `woodpecker_silent_build_fail.md`. **Always verify** `dev-<version>` exists in `git.manko.yoga` before trusting CI green. The wagmi `chainId` field requires `as any` because of its literal-union type — keep that pattern when adding new wagmi calls.
- **Existing/legacy Payment records** (created before backend 2.6.34) don't have RN's request details cached. The GET endpoint lazy-enriches them via `GET /v2/request/:requestId` on first visit, then persists. If RN's API is down at that moment, falls back to the hosted-page link.
- **Mongo access is denied** to the auto-mode classifier on dev — debugging payment records currently requires either the user's mongo creds or relying on the 409 `debug` block surfaced through the frontend.
- **Wagmi provider isolation (2.6.39)**: The checkout page wraps itself in its own `WagmiProvider`. The root `Web3Provider` also renders `WagmiProvider` unconditionally as of 2.6.38. The doubling is intentional defensiveness — if one provider has an issue, the other still serves the checkout flow. Can be simplified later if both prove stable.
- **PRD Phase 5 — Cloudflare Worker durable webhook ingress** — not started. Taskmaster `3.13`. Current dev relies on `dev.amn.gg` being up at the moment RN's webhook fires. For prod, RN webhooks need to land in a durable Cloudflare Worker that buffers + replays into the backend.
## Files changed (recent)
Backend (`/Users/manwe/CascadeProjects/escrow/backend`):
- `src/services/payment/requestNetwork/contract.ts` — spreads full RN response into `raw`
- `src/services/payment/requestNetwork/inHouseCheckout.ts` — block builder, reads `paymentReference` from `rnRaw.requestDetails.paymentReference`
- `src/services/payment/requestNetwork/merchantReference.ts`, `tokens.ts`, `proxyAddresses.ts`, `paymentReference.ts` — helpers
- `src/services/payment/requestNetwork/requestNetworkPayInService.ts` — calls `GET /v2/request` after intent creation
- `src/services/payment/requestNetwork/requestNetworkRoutes.ts``GET /:paymentId/checkout` + lazy enrichment + debug response
- `src/services/payment/requestNetwork/networkClient.ts` — already had `getRequestStatus`
- `src/app.ts``GET /api/version`, exempt from rate limit
- `__tests__/rn-in-house-checkout.test.ts` — 12 unit tests, all green
Frontend (`/Users/manwe/CascadeProjects/escrow/frontend`):
- `src/web3/contracts/rn-fee-proxy.ts` — RN proxy + ERC20 ABIs
- `src/web3/context/wagmi-provider.tsx` — removed the mount-gate that caused `WagmiProviderNotFoundError`
- `src/web3/components/provider-payment.tsx``router.push` to in-house page + sessionStorage stash
- `src/sections/payment/checkout/types.ts` + `rn-in-house-checkout-view.tsx` — state machine, local WagmiProvider wrap
- `src/app/checkout/request-network/[paymentId]/page.tsx` — app router entry
- `src/components/version-logger.tsx` — version chip + tooltip showing backend version
## Memory entries added
- `MEMORY.md` index updated with:
- `arcane_dev_stack.md` (env/project IDs, two-step deploy note)
- `woodpecker_silent_build_fail.md` (CI green ≠ image pushed)
- and existing `rn_webhook_event_field.md`, `backend_rate_limits.md`, `telegram_notify_no_parse_mode.md`, `devEscrow_nginx_after_redeploy.md`, `parallel_agents_on_escrow.md`
## Open PRD questions still to decide
From `PRD - Request Network In-House Checkout.md` §10:
- Proxy address universality across chains (currently BSC + Arb confirmed; Task #8 will probe Polygon/ETH/Base)
- API pricing for hosted-UI-less usage (need RN account-mgmt question)
- Approval UX — exact-amount vs MAX_UINT256 (current: exact-amount)
- Cancel / timeout semantics for abandoned intents
- Telemetry events for in-house vs hosted A/B
## Follow-up tasks (Taskmaster + PRD)
Five follow-ups scoped for kimi to pick up independently. Full spec in `PRD - Wallet, Multichain, Confirmations, AML, Trezor.md`. Quick index:
| # | Task | Priority | Status |
|---|---------------------------------------------------------------|----------|--------|
| 7 | Per-(buyer, sellerOffer) ephemeral RN destination wallets | high | 🟡 In progress — backend + admin UI + cart-aware UX + tests shipped in 2.6.45/2.6.46; **live RN-accepts-divergent-destination probe remains manual** |
| 8 | Multichain RN proxy registry + USDC/USDT support | high | 🟡 In progress — backend registry + probe script + frontend admin page + USDT-mainnet reset shipped in 2.6.46; **BSC USDT paid probe + Base proxy address hunt remain** |
| 9 | Per-chain confirmation thresholds + admin UI | medium | ⏳ Not started |
| 10 | Optional AML screening on incoming payments (seller-paid) | medium | ⏳ Not started |
| 11 | Trezor signing for admin actions (release/refund/sweep) | high | ⏳ Not started |
## Task #7 — what landed in 2.6.42
**Backend** (`backend/src/services/payment/wallets/` + plumbing)
- `DerivedDestination` model: `(buyerId, sellerOfferId, chainId)` → address, derivation path, status, sweep history.
- `derivedDestinations.ts`: xpub-driven HD address derivation, atomic counter-based index allocation, idempotent `getDestinationFor`, race-safe upsert. Backend holds `DERIVED_DESTINATION_XPUB` only — master seed lives in KMS / Trezor (Task #11).
- `sweepService.ts`: pluggable signer abstraction (`build-only` default; `hot-key` for dev), ERC-20 balance queries, sweep orchestration, interval-based cron.
- `derivedDestinationRoutes.ts`: admin-only REST endpoints (list, sweep-all, sweep-one, config health, cron start/stop/status). Mounted at `/api/payment/derived-destinations`.
- `requestNetworkPayInService.ts` now calls `getDestinationFor(buyer, sellerOffer, chainId)`, builds the per-payment merchant reference via `buildMerchantReference`, persists `metadata.derivedDestination`, and passes the override to RN.
- `inHouseCheckout.ts` accepts a `destinationOverride`; the on-chain `paymentReference` compute-fallback now uses the actual destination (previously read `parsed.recipient` — hidden bug because RN's response provides the ref directly, but the fallback was broken for derived destinations).
- `TransactionSafetyProvider.resolveExpectedRecipient` checks `metadata.derivedDestination.address` first, then legacy fallback.
**Frontend** (admin only)
- `/dashboard/admin/derived-destinations` page (table view, filters by status/chain/address, pagination, sweep-all, cron start/stop).
- Per-row UI: address with copy + BscScan link, status chip, derivation path, balance, sweep count, last sweep tx link.
**Env additions** (see `backend/.env.example`):
- `DERIVED_DESTINATION_XPUB` — required for address derivation.
- `DERIVED_DESTINATION_XPRIV` — only when `DERIVED_DESTINATION_SWEEP_SIGNER=hot-key` (dev shortcut).
- `DERIVED_DESTINATION_BASE_PATH=m/44'/60'/0'`
- `DERIVED_DESTINATION_CHAIN_ID=56`
- `DERIVED_DESTINATION_SWEEP_SIGNER=build-only`
- `DERIVED_DESTINATION_MIN_SWEEP_AMOUNT=0`
- `DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000`
## Task #7 — completion status
| Item | Status | Notes |
|------|--------|-------|
| Backend model + HD derivation | ✅ | `DerivedDestination`, `derivedDestinations.ts`, counter-based index |
| Sweep service + cron | ✅ | `sweepService.ts`, `build-only`/`hot-key` signers, auto-start in `app.ts` |
| Admin API + UI | ✅ | `/api/payment/derived-destinations/*`, admin dashboard page |
| RN intent integration | ✅ | `requestNetworkPayInService.ts` passes per-seller destination |
| Transaction Safety Provider | ✅ | `resolveExpectedRecipient` checks `metadata.derivedDestination` first |
| A — Cart-aware buyer UX | ✅ | `MultiSellerProviderPayment` + `RnMultiCheckoutView` + template checkout wiring |
| D — Auto-start sweep cron | ✅ | `app.ts` boots cron when `DERIVED_DESTINATION_SWEEP_AUTOSTART=true` |
| E — `recordSweep` accumulation fix | ✅ | `$inc: { totalSwept }` instead of `$setOnInsert` |
| F — API docs | ✅ | Derived-destination endpoints added to `Payment API.md` |
| B — Unit tests | ✅ | 46 tests across 3 files (see below) |
| C — Live divergent-destination probe | 🔄 | Protocol prepared; **requires manual browser + wallet execution** (see §Live multi-seller probe below). Dev is running 2.6.46 with all backend/frontend code deployed. |
## Task #8 — completion status
| Item | Status | Notes |
|------|--------|-------|
| Probe script | ✅ | `scripts/probe-rn-chains.ts` — 4/5 chains verified (BSC, Arbitrum, Ethereum, Polygon). Base proxy not deployed at canonical address. |
| Token registry JSON | ✅ | `tokens.json` — USDC + USDT on 4 verified chains with correct decimals |
| Chain registry JSON | ✅ | `supportedChains.json` — 4 verified chains + Base in `_unverified` |
| Admin route | ✅ | `GET /api/admin/rn/networks`, `POST /api/admin/rn/networks/reload` |
| Frontend admin page | ✅ | `/dashboard/admin/networks` renders registry with reload button |
| `unsupported_chain` reason | ✅ | `buildInHouseCheckoutBlock` returns `unsupported_chain:<id>` |
| Frontend wagmi multichain | ✅ | Added arbitrum + base to wagmi config with RPC transports |
| Per-chain explorers | ✅ | Checkout view uses correct explorer per chainId |
| USDT-mainnet approve reset | ✅ | **Implemented but unverified** — see note in [[Handoff - RN Multichain Probe - 2026-05-28]] |
| BSC USDT paid probe | 🔄 | **Pending manual execution** — needs real wallet + test BSC USDT |
| Base proxy hunt | 🔄 | Canonical address has no code on Base. Need RN official docs for actual address. |
### B — Unit tests (backend@34f542e, 2.6.45)
**`__tests__/derived-destinations.test.ts`** (26 tests)
- `validateXpub` rejects xpriv, tprv, garbage, empty, null/undefined
- `deriveAddressAtIndex` is deterministic; different indices → different addresses; checksummed; rejects negative/non-integer
- `getDestinationFor` idempotency: same `(buyer, sellerOffer, chainId)` returns same row, counter increments exactly once
- `getDestinationFor` E11000 race fallback: simulates concurrent insert, verify second caller re-reads racer's row
- `getDestinationFor` non-E11000 errors are re-thrown
- `recordSweep` `$inc` accumulation: run twice, assert `totalSwept` equals sum (regression lock-in for item E)
- `recordSweep` handles string and negative amounts
- `resolveExpectedRecipientForPayment` prefers `metadata.derivedDestination`, falls back to `blockchain.receiver`
- `listDerivedDestinations` pagination
- `verifyDerivedDestinationConfig` ok / missing xpub / invalid xpub
**`__tests__/sweep-service.test.ts`** (18 tests)
- `getSweepSigner` returns `build-only` by default, `hot-key` when configured
- `queryTokenBalance` parses bigint, returns 0n for empty balance, null on RPC failure, null for unsupported chain+token
- `sweepDerivedDestinations` skips below-threshold balances, dry-run returns amount without broadcasting, build-only signer returns error without updating record, handles balance query failure, respects `destinationIds` filter, throws when master wallet missing
- Cron lifecycle: start/stop/idempotent/zero-interval
**`__tests__/request-template-orphan-cleanup.test.ts`** (2 tests) — **non-negotiable regression lock-in for Gap 2 fix**
- Asserts `Payment.find` during orphan cleanup is scoped to `provider: 'shkeeper'`
- Asserts a pending `provider: 'request.network'` Payment is **NOT** deleted when a shkeeper orphan exists for the same buyer
- Asserts request.network orphan is untouched even when no shkeeper orphan is present
### C — Live multi-seller probe protocol
**Goal:** Prove RN accepts divergent `destinationId` across consecutive `POST /v2/secure-payments` from the same buyer session, and that the multi-checkout cart UX creates two Payments landing on two different derived addresses.
**Prerequisites:**
- Dev backend running ≥ 2.6.45 with `DERIVED_DESTINATION_XPUB` configured
- Dev frontend running ≥ 2.6.44
- Two seller accounts on `dev.amn.gg` with wallet addresses set
- Buyer account with a Rabby/Metamask wallet holding ≥ 0.02 testnet BSC USDC
**Steps:**
1. **Create two template offers** (one from each seller):
- Seller A: `https://dev.amn.gg/dashboard/shops/templates/new` → fill title, price 0.01 USDC, publish
- Seller B: same, price 0.01 USDC, publish
- Capture both `shareableLink` values
2. **As buyer, add both to cart**:
- Visit Seller A's shareable link → Add to cart
- Visit Seller B's shareable link → Add to cart
- Go to `/dashboard/shops/checkout/?step=2`
- Confirm cart shows 2 items from 2 different sellers
3. **Select crypto payment and proceed**:
- Choose "پرداخت با Request Network"
- Click the multi-seller button (should show `۲ فروشنده`)
- Browser navigates to `/checkout/request-network/multi?session=<id>`
4. **Pay Seller A** (first checkout page):
- Connect wallet
- Approve 0.01 USDC for RN proxy
- Call `transferFromWithReferenceAndFee`
- Wait for "پرداخت تأیید شد ✓"
- Click "ادامه به پرداخت بعدی"
5. **Pay Seller B** (second checkout page):
- Approve 0.01 USDC (or reuse allowance if proxy unchanged)
- Call `transferFromWithReferenceAndFee`
- Wait for confirmation
- Click "پایان"
6. **Capture evidence**:
- **Derived addresses:** Check `GET /api/payment/derived-destinations?buyerId=<buyerId>` — expect 2 rows with different `address` and `derivationIndex`
- **Tx hashes:** Copy both approve and both pay tx hashes from the UI; verify on `https://testnet.bscscan.com` (or mainnet BscScan if dev uses mainnet)
- **Transfer events:** On BscScan, verify both `TransferWithReferenceAndFee` events show different `recipient` addresses
- **Webhooks:** Check backend logs for `payment-update` socket emissions or `POST /api/payment/request-network/webhook` handling for both payments
- **Payment records:** Query Mongo for the two `Payment` docs — both should have `status: 'completed'` and different `metadata.derivedDestination.address`
- **PurchaseRequests:** Verify `convertTemplatesToRequests` created 2 `PurchaseRequest` docs with `status: 'payment'`
7. **Document**: Paste the full evidence block below under "Live multi-seller probe — execution record".
---
### Live multi-seller probe — execution record
> _To be filled after manual execution of the protocol above._
>
> | Field | Value |
> |-------|-------|
> | Date | |
> | Buyer ID | |
> | Seller A ID / Offer | |
> | Seller B ID / Offer | |
> | Derived address A | |
> | Derived address B | |
> | Approve tx A | |
> | Pay tx A | |
> | Approve tx B | |
> | Pay tx B | |
> | Payment ID A | |
> | Payment ID B | |
> | PurchaseRequest ID A | |
> | PurchaseRequest ID B | |
> | RN webhook fired for both? | |
> | Both Payments completed? | |
> | Escrow funded for both? | |
---
**Remaining in task #7:**
- Item C: execute the live probe protocol above and fill the execution record table.
- After C passes: flip PRD §1 acceptance criteria #1 and #2 from ⏳ → ✅ and mark Task #7 done in Taskmaster.

View File

@@ -13,7 +13,7 @@ Runbooks for the most likely production incidents, plus communication templates
| Sev | Meaning | Response time | Examples | | Sev | Meaning | Response time | Examples |
|-----|---------|---------------|----------| |-----|---------|---------------|----------|
| **Sev 1** | Site fully down or unable to process payments | 15 min | Backend container in crashloop; Mongo unreachable; SHKeeper API permanently failing | | **Sev 1** | Site fully down or unable to process payments | 15 min | Backend container in crashloop; Mongo unreachable; Request Network API/webhooks failing |
| **Sev 2** | Major feature broken for a large share of users | 1 hour | Email sending broken; Redis disk full; chat undelivered | | **Sev 2** | Major feature broken for a large share of users | 1 hour | Email sending broken; Redis disk full; chat undelivered |
| **Sev 3** | Minor / cosmetic issue, isolated user reports | next business day | Single failed webhook; one user can't upload PDF | | **Sev 3** | Minor / cosmetic issue, isolated user reports | next business day | Single failed webhook; one user can't upload PDF |
| **Sev 4** | No user impact, hygiene item | backlog | Backup older than 24h; disk > 80%; missed deploy | | **Sev 4** | No user impact, hygiene item | backlog | Backup older than 24h; disk > 80%; missed deploy |
@@ -133,35 +133,34 @@ The app gracefully degrades when Redis is unreachable for short windows — don'
--- ---
### 3.4 SHKeeper API down (payments blocked) ### 3.4 Request Network API/webhook down (payments degraded)
**Symptoms.** Backend logs show repeated `SHKeeper request failed: ECONNREFUSED` or non-2xx responses from `$SHKEEPER_API_URL`. Buyers see "Payment unavailable" in checkout. Sev 1 — money is involved. **Symptoms.** Backend logs show repeated Request Network API failures, webhook delivery failures, or payments stuck in pending/safety-pending. Buyers see "Payment unavailable" or a checkout that never confirms. Sev 1 — money is involved.
**Runbook.** **Runbook.**
```bash ```bash
# 1. Confirm SHKeeper itself is reachable # 1. Confirm Request Network API is reachable
curl -fsS -H "X-Shkeeper-Api-Key: $SHKEEPER_API_KEY" \ curl -fsS -H "Authorization: Bearer $REQUEST_NETWORK_API_KEY" \
"$SHKEEPER_API_URL/api/v1/healthcheck" "$REQUEST_NETWORK_API_BASE_URL"
# 2. If 5xx from SHKeeper → it's their side # 2. If 5xx from Request Network -> provider/API side
# - Check their status page / contact provider # - Check their status page / contact provider
# - Toggle a banner in the frontend warning buyers # - Toggle a banner in the frontend warning buyers
# - Consider switching SHKEEPER_FORCE_PAYOUT_DEMO=true so QA still works # - Pause new checkout creation if confirmations cannot be reconciled
# (do NOT do this for real customer money)
# 3. If our network can't reach it: # 3. If our network can't reach it:
# - test from the host: curl from the host vs from inside the container # - test from the host: curl from the host vs from inside the container
docker exec nickapp-backend curl -v "$SHKEEPER_API_URL" docker exec nickapp-backend curl -v "$REQUEST_NETWORK_API_BASE_URL"
# - DNS / firewall changes? # - DNS / firewall changes?
# 4. While blocked, monitor stuck payments # 4. While blocked, monitor stuck payments
docker exec nickapp-mongodb mongosh --eval \ docker exec nickapp-mongodb mongosh --eval \
"use marketplace; db.payments.find({status:'pending', createdAt:{\$lt: new Date(Date.now() - 30*60*1000)}}).count()" "use marketplace; db.payments.find({status:'pending', createdAt:{\$lt: new Date(Date.now() - 30*60*1000)}}).count()"
# 5. Once SHKeeper is back, the app retries automatically. Verify the # 5. Once Request Network/webhook delivery is back, replay or reconcile
# backlog drains. If a payment is stuck > 24h, manually verify against # pending events. If a payment is stuck > 24h, manually verify the
# SHKeeper and use fix-transaction-hashes.js if needed. # on-chain transfer, Transaction Safety Provider result, and ledger state.
``` ```
**Always communicate.** Even short payment outages erode trust — post a status update. **Always communicate.** Even short payment outages erode trust — post a status update.
@@ -260,6 +259,20 @@ If user data may have leaked, treat as sev 1 and follow your data-breach disclos
Use when Request Network payments are failing, stalled, or out of sync with local payment state. Use when Request Network payments are failing, stalled, or out of sync with local payment state.
**First triage:**
1. Check whether RN reached nginx:
```bash
grep '/api/payment/request-network/webhook' /opt/backend/nginx/logs/access.log | tail -50
```
2. If RN deliveries returned `404`, treat it as a backend correlation/config bug. Do not run another paid probe until the correlation fix is deployed and smoke-tested.
3. If deliveries returned `202` or `200` but the payment is still pending, inspect `metadata.transactionSafety` on the `Payment` document. A safety-pending payment is captured but not credited; look for missing tx hash, insufficient confirmations, transfer mismatch, or AML provider blockers.
4. If Cloudflare Worker durable ingress is enabled, replay from the Worker delivery id/time window after backend repair instead of asking the buyer to pay again.
**Immediate rollback (minutes):** **Immediate rollback (minutes):**
1. Stop routing new intents to Request Network by setting: 1. Stop routing new intents to Request Network by setting:
@@ -408,7 +421,7 @@ Store postmortems alongside this vault — suggested path `/Users/mojtabaheidari
| Payments lead | <name> | <name> | DM | | Payments lead | <name> | <name> | DM |
| Infrastructure | <name> | <name> | DM | | Infrastructure | <name> | <name> | DM |
| Product / customer comms | <name> | <name> | #customer-comms | | Product / customer comms | <name> | <name> | #customer-comms |
| SHKeeper provider contact | <email> | — | email | | Request Network/provider contact | <email> | — | email |
| SMTP provider | <email> | — | email | | SMTP provider | <email> | — | email |
--- ---

View File

@@ -11,24 +11,21 @@ What's instrumented today and what to watch. Today's stack is intentionally lean
## 1. Health endpoint ## 1. Health endpoint
Path: `GET /health` (backend, port `5001`). Two paths are registered (both are public, rate-limited, not auth-gated):
Defined in `backend/src/app.ts`: - `GET /health` — simple ping used by Docker healthchecks. Returns `200 { success, message, timestamp, environment, version }`. Does **not** probe MongoDB or Redis.
- `GET /api/health` — deep health check added in commit `44579d6` (backend v2.6.49). Calls `runHealthChecks` from `backend/src/services/health/healthCheckService.ts`. Probes MongoDB and Redis, collects memory/uptime stats, and returns a structured report. Returns `503` when `report.status === 'down'`.
```ts `GET /api/health` response shape (from `healthCheckService`):
app.get("/health", (req, res) => { ```json
res.json({ {
success: true, "status": "ok",
message: "Marketplace Backend API is running", "version": "2.6.xx",
timestamp: new Date().toISOString(), "timestamp": "...",
environment: config.nodeEnv, "checks": { "mongodb": "ok", "redis": "ok", "uptime": 3600, "memoryMB": 120 }
version: packageJson.version, }
});
});
``` ```
Returns `200` with a JSON envelope as soon as Express is up. Does **not** currently probe MongoDB or Redis — they are checked via separate Docker healthchecks. If you want deep health, extend the endpoint to ping both data stores and return `503` on failure.
Public URL behind Nginx: `https://amn.gg/api/health`. Public URL behind Nginx: `https://amn.gg/api/health`.
--- ---
@@ -134,7 +131,7 @@ Notable log lines to look for:
| `🚀 Server running on port 5001` | App fully started | | `🚀 Server running on port 5001` | App fully started |
| `🔌 User connected: <id>` | Socket.IO connection | | `🔌 User connected: <id>` | Socket.IO connection |
| `📥` | Inbound HTTP request log | | `📥` | Inbound HTTP request log |
| `💳 SHKeeper` | SHKeeper webhook / API call | | `💳 Request Network` | Request Network webhook / API call |
| `🔐 Webhook verification` | Webhook signature check result | | `🔐 Webhook verification` | Webhook signature check result |
| `❌ Error` | Manual error log (also captured by Sentry) | | `❌ Error` | Manual error log (also captured by Sentry) |
@@ -181,7 +178,9 @@ Today these are read manually from logs / Sentry. As Prometheus is added, encode
|--------|-------|---------|-------| |--------|-------|---------|-------|
| Payment success rate | `db.payments.aggregate([{$group:{_id:"$status",n:{$sum:1}}}])` | > 95 % completed of 24h-old payments | < 90 % | | Payment success rate | `db.payments.aggregate([{$group:{_id:"$status",n:{$sum:1}}}])` | > 95 % completed of 24h-old payments | < 90 % |
| Webhook signature failures | log `Webhook verification failed` | 0 | > 0 | | Webhook signature failures | log `Webhook verification failed` | 0 | > 0 |
| SHKeeper API errors (5xx) | log + Sentry | 0 | > 5/min sustained | | Request Network webhook 4xx | nginx access log `/api/payment/request-network/webhook` | 0 | any real provider delivery returning 4xx |
| Request Network safety-pending payments | `db.payments.find({"metadata.transactionSafety.status":"pending"})` | explained/short-lived | pending > 10 min without operator note |
| Request Network API errors (5xx) | log + Sentry | 0 | > 5/min sustained |
| Payouts stuck in `pending` > 30 min | `db.payments.find({type:'payout',status:'pending',createdAt:{$lt:ISODate(30 min ago)}})` | empty | non-empty | | Payouts stuck in `pending` > 30 min | `db.payments.find({type:'payout',status:'pending',createdAt:{$lt:ISODate(30 min ago)}})` | empty | non-empty |
| Missing `transactionHash` after `completed` | the same query that drives `fix-transaction-hashes.js` | empty | non-empty | | Missing `transactionHash` after `completed` | the same query that drives `fix-transaction-hashes.js` | empty | non-empty |

View File

@@ -10,7 +10,7 @@ Date: 2026-05-24
Scope: Scope:
- Task 3 provider-neutral payment migration. - Task 3 provider-neutral payment migration.
- Request Network optional pay-in, webhook, and reconciliation support. - Request Network primary pay-in, webhook, and reconciliation support.
- Internal funds ledger and release/refund ledger gates. - Internal funds ledger and release/refund ledger gates.
- Optional Trezor safekeeping support. - Optional Trezor safekeeping support.
@@ -84,7 +84,7 @@ Before enabling Request Network for a non-test cohort:
2. Run backend typecheck. 2. Run backend typecheck.
3. Test one Request Network sandbox pay-in with webhook callback. 3. Test one Request Network sandbox pay-in with webhook callback.
4. Confirm reconciliation dry-run output is empty or expected. 4. Confirm reconciliation dry-run output is empty or expected.
5. Keep `PAYMENT_ROLLBACK_PROVIDER=shkeeper`. 5. Keep the Request Network rollback/support runbook current; SHKeeper is historical context, not the current primary rollback target.
Before enabling Trezor safekeeping enforcement: Before enabling Trezor safekeeping enforcement:

View File

@@ -0,0 +1,220 @@
---
title: Scanner Operations
tags: [operations, scanner, deployment]
created: 2026-05-30
---
# Scanner Operations
Runbook for deploying, configuring, monitoring, and troubleshooting the AMN Pay Scanner microservice.
---
## 1. Configuration reference
All configuration via environment variables. See `.env.example` in the scanner repo.
| Variable | Default | Required | Description |
|---|---|---|---|
| `PORT` | `8080` | no | HTTP listen port |
| `DB_PATH` | `./scanner.db` | no | SQLite database path |
| `CHAINS_JSON_PATH` | `./supported-chains.json` | no | Supported chains config |
| `TOKENS_JSON_PATH` | `./tokens.json` | no | Token registry |
| `SCANNER_API_KEY` | _(none)_ | **yes (prod)** | Bearer token for all non-health endpoints. Generate with `openssl rand -hex 32` |
| `POLL_INTERVAL_SEC` | `15` | no | Chain poll interval in seconds |
| `INTENT_TTL_HOURS` | `24` | no | Pending/confirming intents older than this are expired (0 = disabled) |
| `WEBHOOK_RETRY_HOURS` | `6` | no | Interval between automatic webhook_failed re-delivery passes (0 = disabled) |
| `TRONGRID_API_KEY` | _(none)_ | recommended | TronGrid API key; without it rate limits are very low |
| `TONCENTER_API_KEY` | _(none)_ | recommended | TonCenter API key |
| `RPC_BSC` | _(chain config)_ | no | Override BSC RPC URL (chain 56) |
| `RPC_ARB` | _(chain config)_ | no | Override Arbitrum RPC URL (chain 42161) |
| `RPC_ETH` | _(chain config)_ | no | Override Ethereum RPC URL (chain 1) |
| `RPC_POLYGON` | _(chain config)_ | no | Override Polygon RPC URL (chain 137) |
| `RPC_BASE` | _(chain config)_ | no | Override Base RPC URL (chain 8453) |
> [!warning]
> If `SCANNER_API_KEY` is not set, the scanner logs a warning and accepts all requests. Never run this way in production.
---
## 2. Docker deployment
The scanner ships as a single Docker image. The Dockerfile uses a two-stage build (Go 1.25 builder → Alpine 3.21 runtime).
### Quick start (dev)
```bash
cd scanner/
cp .env.example .env
# edit .env — set SCANNER_API_KEY, RPC overrides, etc.
docker build -t amn-scanner:dev .
docker run -d \
--name amn-scanner \
-p 8080:8080 \
-v $(pwd)/data:/data \
--env-file .env \
amn-scanner:dev
```
### Production (via arcane-cli / Watchtower)
The scanner is deployed manually via `arcane-cli` (not gitops). Watchtower does NOT manage it automatically. After pushing a new image, redeploy with:
```bash
arcane-cli project redeploy --json <project-id>
```
The SQLite database is stored on a named Docker volume (`/data`). Do not recreate the volume between deploys — it holds the checkpoint and intent state.
---
## 3. Health check
```bash
curl http://localhost:8080/health
# {"status":"ok","time":"2026-05-30T12:00:00Z"}
```
Docker `HEALTHCHECK` is already configured in the Dockerfile (30 s interval, 5 s timeout, 3 retries).
---
## 4. Monitoring
### Scanner status endpoint
```bash
curl -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/scanner/status | jq .
```
Check:
- `lag` — should be near 0 for healthy chains (blocks behind for EVM, seconds for TON)
- `pendingIntents` — number of unresolved intents per chain
- `lastScannedBlock` — should advance each poll
### Logs
The scanner uses Go's `log/slog` structured logger with level prefixes. Key log patterns:
| Pattern | Meaning |
|---|---|
| `[scanner] worker started` | Worker goroutine began for this chain |
| `[evm] intent confirming` | EVM tx seen, waiting for confirmations |
| `[evm] intent confirmed` | EVM: N confirmations reached |
| `[tron] MATCH` / `[ton] MATCH` | Transfer matched, going to confirmed |
| `[webhook] delivered` | Webhook POST succeeded |
| `[webhook] non-2xx response` | Backend returned error (will retry) |
| `[webhook] all retries exhausted` | Intent moved to webhook_failed |
| `[scanner] reconciling confirmed intents` | Startup crash recovery in progress |
| `[evm] scanner lag` | Chain lag > 100 blocks (investigate RPC) |
---
## 5. Adding / modifying chains
Edit `supported-chains.json`. Fields:
| Field | Notes |
|---|---|
| `chainId` | Numeric EIP-155 chain ID (arbitrary int for Tron/TON) |
| `chainType` | `"evm"` (default) / `"tron"` / `"ton"` |
| `rpcUrl` | Primary RPC endpoint |
| `publicRpcUrl` | Fallback RPC (EVM only) |
| `proxyAddress` | ERC20FeeProxy address (EVM); USDT contract (Tron); USDT Jetton master (TON) |
| `confirmationThreshold` | Blocks required (EVM); ignored for Tron/TON |
| `verified` | `true` to activate the worker; `false` to disable without deleting |
> [!important]
> Changing `proxyAddress` for an EVM chain only affects new scans. Existing pending intents will still be matched against the old address until they expire or are confirmed.
After editing, restart the scanner container to pick up the new config.
---
## 6. Adding tokens to the registry
Edit `tokens.json`. Each entry:
```json
{ "chainId": 56, "address": "0x...", "symbol": "USDC", "decimals": 18, "name": "USD Coin" }
```
Token registry is used only for populating `tokenSymbol` and `decimals` in the `checkoutBlock` response. Omitting a token does not break scanning — it just leaves those fields empty.
---
## 7. Manual webhook retry
Force immediate re-delivery of all `webhook_failed` intents:
```bash
curl -X POST -H "Authorization: Bearer $SCANNER_API_KEY" \
http://localhost:8080/admin/webhooks/retry
# {"queued": N}
```
---
## 8. Database inspection
The SQLite database (`/data/scanner.db`) can be inspected with the `sqlite3` CLI inside the container:
```bash
docker exec -it amn-scanner sqlite3 /data/scanner.db
# Check stuck intents
SELECT intent_id, chain_id, status, created_at, webhook_delivered_at
FROM intents
WHERE status NOT IN ('confirmed', 'expired')
ORDER BY created_at DESC;
# Check chain checkpoints
SELECT chain_id, last_scanned_block, updated_at FROM checkpoints;
# Count by status
SELECT status, count(*) FROM intents GROUP BY status;
```
---
## 9. Troubleshooting
### Intent stuck in `pending`
1. Check `/scanner/status` — is the chain worker running and advancing (`lag` > 0 for a long time = RPC issue)?
2. Check that `chainId` and `tokenAddress` match exactly what is in `supported-chains.json` and `tokens.json`.
3. For EVM: verify the `proxyAddress` matches the contract the buyer is calling.
4. For Tron: confirm the destination address is stored in EVM-hex (0x) format in the DB.
5. Check scanner logs for `REJECT` messages around the expected tx time.
### Webhook never received by backend
1. Check `webhook_delivered_at` in the DB — if not null, the scanner delivered successfully and the backend side is the issue.
2. If null and status is `webhook_failed`: check backend logs for the incoming POST; verify `X-AMN-Signature` validation code.
3. If status is `confirmed` but `webhook_delivered_at` is null: startup reconciliation may re-deliver on next restart.
4. Use `POST /admin/webhooks/retry` to trigger immediate retry.
### High lag on EVM chain
1. Check RPC endpoint availability and rate limits.
2. Consider setting a `RPC_*` env override to a premium RPC (Alchemy, Infura, QuickNode).
3. The scanner falls back to `publicRpcUrl` if the primary fails but public nodes have lower limits.
### Intent confirmed but amount looks wrong
The scanner accepts any amount **>=** `intent.Amount`. Overpayments are not flagged. Underpayments result in the intent staying pending until TTL expiry.
---
## 10. CI/CD notes
- Woodpecker CI pipeline is in `.woodpecker/`.
- Telegram notify steps were removed (no TG secrets configured).
- Deploy step was removed — the scanner is deployed manually via `arcane-cli`.
- The CI pipeline builds and pushes the Docker image to the Gitea registry.
- Image tag format: `dev-<VERSION>` (from the `VERSION` file).
> [!tip]
> After CI completes, verify the image is in the registry before redeploying. Silent CI failures can leave a stale image tagged. Check the registry tag timestamp, not just the CI green light.

View File

@@ -0,0 +1,105 @@
---
title: Secret Rotation Runbook — 2026-05-30
tags: [operations, security, secrets, incident]
created: 2026-05-30
status: action-required
source: Full Codebase Audit - 2026-05-30
---
# Secret Rotation Runbook — 2026-05-30
The 2026-05-30 full codebase audit found live credentials committed to the repos and, in
some cases, baked into container images. The audit's no-brainer fixes **replaced the
committed values with placeholders in the working tree**, but the *real* credentials are
still valid and must be **rotated by a human** — replacing a string in git does not
invalidate a leaked key.
> Treat every credential below as **compromised**. Anyone with repo (or image) access has
> had these values. Rotate first, then scrub history.
Related issues: ISSUE-074, ISSUE-075, ISSUE-079, ISSUE-115 and decisions DEC-49, DEC-50,
DEC-56, DEC-74, DEC-75, DEC-78.
---
## Order of operations (per credential)
1. **Rotate** — generate a new value at the provider.
2. **Inject at runtime** — put the new value in the deployment secret store (Arcane env /
compose secrets), **never** back into a committed file.
3. **Deploy** — roll the new value out and confirm the service is healthy.
4. **Revoke** — invalidate the old value at the provider.
5. **Scrub** — remove the secret from git history (see "History scrub" at the bottom).
Do these one credential at a time and verify the dependent service after each.
---
## Credentials to rotate
| # | Credential | Where it leaked | Blast radius | How to rotate |
|---|-----------|-----------------|--------------|---------------|
| 1 | **Telegram bot token** | `backend/.env.development`, `backend/.env.example`, `frontend/.gitleaks.toml` | Full control of the bot: read/send messages, hijack the login widget, phish users | BotFather → `/revoke` → new token. Update `TELEGRAM_BOT_TOKEN`. |
| 2 | **Resend SMTP / API key** | `backend/.env.development`, `backend/.env.example` | Send email as the platform (phishing, OTP spoofing), read sending logs | Resend dashboard → API Keys → delete + create. Update `RESEND_API_KEY` / SMTP creds. |
| 3 | **JWT signing secret** | `backend/.env.example` | Forge **any** user/admin session token — critical | Generate 32+ random bytes (`openssl rand -hex 32`). Update `JWT_SECRET`. **Rotating invalidates all sessions** (users re-login). Consider also adding a separate `REFRESH_TOKEN_SECRET` (see DEC-26). |
| 4 | **Admin bootstrap password** | `backend/.env.example`, was also a hardcoded fallback in `init-admin.ts` (removed by NB-20) | Direct admin login | Set a strong `ADMIN_PASSWORD` secret; change the admin account password in-app; confirm `init-admin` no longer has a fallback. |
| 5 | **Request Network API key** | `backend/.env.example` | Act against the RN account; manipulate payment intents | RN dashboard → rotate key. Update `REQUEST_NETWORK_API_KEY`. |
| 6 | **Request Network webhook secret** | `backend/.env.example` | Forge RN webhooks → mark payments paid (this is the HMAC secret the backend verifies) | Rotate at RN; update `REQUEST_NETWORK_WEBHOOK_SECRET`. |
| 7 | **Telegram webhook secret token** | `backend/.env.example` | Forge Telegram webhook calls | Reset via `setWebhook` with a new `secret_token`; update the env var. |
| 8 | **Google OAuth client secret** | `backend/.env.example` | Impersonate the OAuth app | Google Cloud Console → Credentials → reset client secret. Update `GOOGLE_CLIENT_SECRET`. |
| 9 | **Alchemy API key(s)** | `frontend/Dockerfile` ARG defaults (removed by NB-10) | Quota theft / RPC abuse on your account | Alchemy dashboard → rotate app key. Supply via CI build-arg / runtime, not a default. |
| 10 | **TG_NOTIFY_BOT_TOKEN** (ops alert bot) | backend startup notification (committed env) | Spoof ops alerts; spam the ops channel | BotFather → revoke → new token. Update `TG_NOTIFY_BOT_TOKEN`. See [[telegram_notify_no_parse_mode]]. |
| 11 | **Frontend test account password** (`Moji6364`) | `frontend/scripts/show-credentials.sh` (DEC-75) | Login as that test user if it exists in any real env | Delete the script (or env-prompt it); rotate the account password if real. |
### Public-by-design (lower priority, but make explicit)
- **WalletConnect project ID**, **Google OAuth *client ID*** — `frontend/Dockerfile` ARG
defaults (DEC-74). These are public values, but remove the baked defaults and pass them
via CI build-args so forks don't reuse the production IDs.
---
## Stop re-leaking (pairs with rotation)
These are the structural fixes (tracked as decisions) that stop the secrets coming back:
- **DEC-50 / ISSUE-075** — `backend/.dockerignore` whitelists `.env.development` *into the
prod image*. Remove the `!.env.development` line so no env file is ever copied into an
image; inject secrets at runtime.
- **DEC-49 / ISSUE-101** — `backend/src/shared/config/index.ts` loads `.env.development`
unconditionally. Load `.env.<NODE_ENV>` (or nothing in production) and never fall back to
the dev file.
- **DEC-56 / ISSUE-074** — untrack `backend/.env.development` entirely (`git rm --cached`)
and add it to `.gitignore`.
- **DEC-78 / ISSUE-079** — `frontend/.gitleaks.toml` allowlists the bot token *by value*.
Switch to a path/fingerprint-based allowlist after scrubbing, so gitleaks stops
"approving" the secret. See the `handle-gitleaks` skill.
Runtime injection point for this stack: the **Arcane** env / project config (see
[[arcane_dev_stack]], [[arcane_cli_usage]]) for dev, and the production secret store for
prod. After changing any backend secret, remember the dev redeploy caveat:
restart `nickDev-nginx` (see [[devEscrow_nginx_after_redeploy]]).
---
## History scrub (after rotation + revocation)
Only after the old values are revoked, purge them from history so they can't be mined from
old commits:
1. Use `git filter-repo` (preferred) or BFG to remove the affected files/blobs from each
repo's history: `backend/.env.development`, the historical `backend/.env.example`,
`frontend/.gitleaks.toml` values, `frontend/scripts/show-credentials.sh`.
2. Force-push the rewritten history and have all collaborators re-clone. **Coordinate**
per [[parallel_agents_on_escrow]] another agent pushes to these branches; a history
rewrite mid-flight will conflict badly. Pick a quiet window.
3. Re-run gitleaks to confirm the working tree and history are clean.
---
## Verification checklist
- [ ] Each credential rotated at the provider and old value **revoked**.
- [ ] New values present only in the runtime secret store (no committed file holds a real value).
- [ ] Backend boots; `/api/health` green; login, email send, Telegram login, and an RN webhook all succeed with new secrets.
- [ ] `.env.development` untracked; `.dockerignore` no longer whitelists it; config no longer loads it in prod.
- [ ] gitleaks passes on working tree; history scrubbed and force-pushed in a coordinated window.

View File

@@ -0,0 +1,43 @@
# TODO: Secret Management Overhaul + Deploy Migration
> Status: **Deferred — created 2026-05-29**
> Owner: infra / nick
> Trigger: discovered while wiring Telegram startup notifications (2026-05-29)
A dedicated pass to (a) rotate every secret that has lived in git, (b) move secrets out of committed files into the right injection layer, and (c) collapse the two-stack deploy split. All dev data is disposable (no data-migration concern).
## 1. Rotate secrets (all have been committed in git → treat as compromised)
Rotate at the provider, then place per the build-vs-runtime split (decided 2026-05-29).
**→ Runtime (live-stack env injection):**
- `JWT_SECRET` (rotation logs everyone out — fine)
- `MONGODB_URI` password + `MONGO_INITDB_ROOT_PASSWORD` (same value)
- `REDIS_URI` password
- `ADMIN_PASSWORD`
- `GOOGLE_CLIENT_SECRET` (Google Cloud Console)
- `SMTP_PASS` (Resend dashboard — revoke + new API key)
- `REQUEST_NETWORK_API_KEY` (RN dashboard)
- `REQUEST_NETWORK_WEBHOOK_SECRET` (+ update RN webhook config)
- `TELEGRAM_BOT_TOKEN` (Mini App bot — @BotFather /revoke)
- `TELEGRAM_WEBHOOK_SECRET_TOKEN` (+ re-set on Telegram setWebhook)
- `TG_NOTIFY_BOT_TOKEN` (amnGG_MonitorBot — already a dedicated bot; rotate if desired)
**→ Build-time (frontend Woodpecker secret, baked into image):**
- `NEXT_PUBLIC_ALCHEMY_API_KEY_*` (one secret `alchemy_api_key` feeds all 3 args) — rotate in Alchemy **and domain-restrict to dev.amn.gg** (it ships in the browser bundle, so the origin allowlist is the real protection).
**Public by design — no rotation:** Google client IDs, `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` (domain-restrict instead), `NEXT_PUBLIC_TELEGRAM_BOT_ID`, wallet addresses, `REQUEST_NETWORK_MERCHANT_REFERENCE`, URLs/flags.
## 2. Strip secrets from committed files
- `backend/.env.example` — currently holds live values; reduce to reference-only (keys + non-secret defaults). Safe: it is **docs-only**, not read at runtime.
- `frontend/.env.production` — same; not copied into the runtime image by the Dockerfile.
- Remove the gitleaks token allowlists once tokens are gone.
## 3. Collapse the two-stack deploy split
See [[deploy_architecture_two_stacks]] (memory). Make `escrow-deploy` (gitops from `nick/deploy`) the canonical live stack; retire `devEscrow`; point the Woodpecker redeploy step at `escrow-deploy` (`8cbe7b2a…`) so sync + redeploy target the same project. This also fixes the cosmetic redeploy **400**.
- Pending local edits already staged for this in `~/CascadeProjects/escrow/deployment` (uncommitted): `TG_NOTIFY_BOT_TOKEN` + `TG_NOTIFY_CHATS` added to `.env` and wired into both service `environment:` blocks in `docker-compose.yml`.
- Container names are identical across both stacks → cutover has a brief collision/downtime window (acceptable; test data).
## Injection model (decided)
- Build-time `NEXT_PUBLIC_*` → Woodpecker secrets (frontend `build_args`).
- Runtime secrets → live-stack env (deploy-repo `.env` once `escrow-deploy` is canonical).

View File

@@ -0,0 +1,217 @@
# Task #11 Pre-flight Inventory — Trezor Signing for Admin Actions
> Status: **Findings / design review** — do not implement until human probes for #7C and #8 are complete.
> Date: 2026-05-28
> Scope: Hardware-wallet signing for sweep, release, and refund admin actions. Backend already has xpub derivation, registration, and message-formatting infrastructure. This inventory covers what is **missing** on the frontend and what the end-to-end flow looks like.
---
## 1. Library choice: `@trezor/connect-web`
### Option matrix
| Library | Maturity | Browser support | Bundle size | Recommendation |
|---|---|---|---|---|
| `@trezor/connect-web` | Official, actively maintained by SatoshiLabs | Chrome/Edge/Brave (WebUSB); Firefox requires Trezor Bridge | ~200 KB compressed | **✅ Use this** |
| `trezor-connect` (legacy) | Deprecated, v8 frozen | Same as above | Larger | ❌ Do not use — no longer updated |
| `@trezor/connect` (node/headless) | For server-side or Electron | N/A (no browser popup) | Smaller | ❌ Wrong environment — we need browser UI |
### Why `@trezor/connect-web`
- The Trezor team consolidated on `@trezor/connect-web` as the single browser SDK. It injects a secure iframe from `https://connect.trezor.io/<version>/iframe.html` and opens a trusted popup for device interaction.
- **WebUSB** works on Chromium-based browsers (Chrome, Edge, Brave, Arc) without any native software. **Firefox** falls back to Trezor Bridge, which most admin users already have installed via Trezor Suite.
- The API surface is promise-based and Typescript-friendly:
```ts
import TrezorConnect from '@trezor/connect-web';
await TrezorConnect.init({
lazyLoad: true,
manifest: { email: 'dev@amn.gg', appUrl: 'https://dev.amn.gg' },
});
const result = await TrezorConnect.ethereumSignTransaction({
path: "m/44'/60'/0'/0/0",
transaction: {
to: '0x...',
value: '0x0',
gasPrice: '0x...',
gasLimit: '0x...',
nonce: '0x...',
chainId: 56,
data: '0x...', // ERC-20 transfer or contract call
},
});
```
- **Mobile is out of scope** — WebUSB does not work on iOS Safari, and Android support is spotty. Admin actions are desktop-only by design.
### Installation
```bash
cd frontend && npm install @trezor/connect-web
```
---
## 2. Dev vs. prod signing flow — end-to-end
### Current state (backend already shipped)
The backend has a complete `TrezorAccount` model, xpub-based HD derivation, registration challenge/response, and operation-message formatting. The `releaseRefundService` already calls `assertTrezorSignatureForOperation()` when `TREZOR_SAFEKEEPING_REQUIRED=true`. The `sweepService` has a `SweepSigner` abstraction with a `HotKeySweepSigner` and a `BuildOnlySigner` (returns the tx without signing). What is missing is a `TrezorSweepSigner` and the frontend connector.
### Proposed dev/prod flow
#### Step A — Admin registers Trezor (already works backend-only)
1. Admin opens `/dashboard/admin/trezor-register`.
2. Frontend calls `TrezorConnect.getPublicKey({ coin: 'ETH', path: "m/44'/60'/0'/0" })`.
3. Device shows popup; admin confirms.
4. Frontend receives `xpub` + first derived address (`m/44'/60'/0'/0/0`).
5. Frontend calls `GET /api/trezor/registration-message?xpub=...&registrationAddress=...`.
6. Frontend calls `TrezorConnect.signMessage({ path: "m/44'/60'/0'/0/0", message: <challenge>, coin: 'ETH' })`.
7. Frontend `POST /api/trezor/register` with xpub, registration address, proof message, and proof signature.
8. Backend verifies and stores the account.
#### Step B — Admin triggers a sweep/release/refund
1. Admin opens `/dashboard/admin/sweeps` (or release/refund UI) and clicks "Execute sweep" on a pending destination.
2. Frontend calls `POST /api/admin/actions/build-tx` (new endpoint needed) with:
```json
{ "action": "sweep", "destinationId": "...", "chainId": 56 }
```
3. Backend builds the unsigned transaction (same logic as `BuildOnlySigner`), estimates gas, computes nonce, and returns:
```json
{
"unsignedTx": {
"to": "0x...",
"data": "0x...",
"value": "0x0",
"gasLimit": "0x...",
"gasPrice": "0x...",
"nonce": 42,
"chainId": 56
},
"derivationPath": "m/44'/60'/0'/0/7",
"txIntentHash": "0x..."
}
```
4. Frontend displays a confirmation modal showing:
- From address (derived from xpub at the returned path)
- To address
- Token + amount
- Network
- Gas estimate
5. Admin clicks "Sign with Trezor".
6. Frontend calls `TrezorConnect.ethereumSignTransaction({ path, transaction: unsignedTx })`.
7. Device shows popup with tx details; admin physically confirms on device.
8. Frontend receives the signed transaction bytes (`result.payload.serializedTx`).
9. Frontend broadcasts via wagmi's `sendTransaction({ raw: serializedTx })` or ethers `provider.broadcastTransaction(serializedTx)`.
10. After broadcast, frontend calls `POST /api/admin/actions/confirm-tx` with:
```json
{
"action": "sweep",
"destinationId": "...",
"txHash": "0x...",
"trezor": {
"message": "Amanat escrow Trezor transaction approval\n...",
"signature": "0x..."
}
}
```
11. Backend verifies the Trezor signature against the registered xpub, appends the ledger entry, and marks the sweep complete.
### Key design decisions to review
| Decision | Option A (recommended) | Option B |
|---|---|---|
| **Who broadcasts?** | Browser (wagmi/ethers) — backend never sees raw signed bytes | Backend receives signed tx and broadcasts |
| **Why A?** | Backend holding a signed tx is almost as sensitive as holding a private key. Browser broadcast keeps the signature in userland. | Simpler for unreliable browser networks, but increases backend attack surface. |
| **Message signing vs tx signing** | Use `ethereumSignTransaction` for actual sweeps; use `signMessage` for the registration proof and for release/refund operation intents | Use `signMessage` for everything — but then backend must reconstruct and verify the tx hash, which is fragile |
| **Derivation path discovery** | Backend tells frontend which path to use (from `DerivedDestination` record). Frontend does not iterate. | Frontend derives addresses from xpub locally to find the right one — more client-side code, more exposure |
---
## 3. Admin UI surface needed
### New pages / sections
| Route | Purpose | Admin role |
|---|---|---|
| `/dashboard/admin/trezor-register` | Register a Trezor xpub, verify first derived address, label device | `superadmin` |
| `/dashboard/admin/trezor-status` | Show registered device, xpub fingerprint, derived addresses in use, last activity | `superadmin` |
| `/dashboard/admin/sweeps` | List pending derived destinations awaiting sweep; "Build tx" → "Sign with Trezor" → "Broadcast" flow | `admin` |
| `/dashboard/admin/pending-actions` | **NEW** — unified queue of all actions awaiting Trezor signature (sweeps, releases, refunds). Shows who requested, when, amount, and a "Sign now" button. | `admin` |
### `/dashboard/admin/pending-actions` — the critical new UI
This is the biggest gap. Today, sweeps are either cron-fired or triggered ad-hoc. With Trezor, every sweep becomes a human-in-the-loop action because the device must be present to sign. The admin needs a queue.
**Proposed UI elements:**
1. **Pending queue table**
- Columns: Action type (sweep / release / refund), Payment/Destination ID, Amount + token, Chain, Requested by, Requested at, Status (`pending_signature` / `signed_broadcasting` / `confirmed` / `failed`)
- Row actions: "View tx details", "Sign with Trezor", "Cancel" (superadmin only)
2. **Tx detail modal**
- Shows the unsigned tx JSON in human-readable form (from, to, token, amount, gas)
- Shows the derivation path and how it maps to the registered Trezor
- "Sign with Trezor" button → triggers `@trezor/connect-web` flow
3. **Signing state machine**
- `idle` → `building_tx` → `awaiting_device` (popup open) → `signing` (user confirming on device) → `broadcasting` → `confirmed` / `failed`
- Each state shows a distinct UI indicator so the admin knows the device is waiting for them
4. **Break-glass override**
- A "Use hot-key override" button visible only to `superadmin`
- Clicking it shows a warning: "This bypasses Trezor safekeeping and triggers a Telegram alarm. Are you sure?"
- If confirmed, frontend calls `POST /api/admin/actions/break-glass` which toggles hot-key signing for 1 hour and sends alarm
### Components to build (frontend)
```
frontend/src/sections/admin/trezor/
trezor-register-view.tsx # Registration flow
trezor-status-view.tsx # Device status + derived addresses
pending-actions-view.tsx # Queue of actions awaiting signature
trezor-sign-modal.tsx # Tx detail + sign button + state machine
hooks/
useTrezorConnect.ts # Wraps @trezor/connect-web init + methods
useTrezorSignTransaction.ts # Handles ethereumSignTransaction flow
usePendingActions.ts # Polls /api/admin/pending-actions
```
---
## 4. Backend gaps to fill (minor)
The backend is ~70% complete for Trezor. Remaining work:
| Gap | Effort | Notes |
|---|---|---|
| `POST /api/admin/actions/build-tx` | Small | Reuses `BuildOnlySigner` logic; returns unsigned tx + derivation path |
| `POST /api/admin/actions/confirm-tx` | Small | Reuses existing `releaseRefundService` / sweep confirmation; adds Trezor proof verification |
| `POST /api/admin/actions/break-glass` | Small | Toggles env override for 1h, sends Telegram alarm, logs audit entry |
| `GET /api/admin/pending-actions` | Small | Queries `DerivedDestination` (status=`awaiting_sweep`) + Payment (status=`awaiting_release`/`awaiting_refund`) |
| `TrezorSweepSigner` class | Small | Implements `SweepSigner` interface; instead of signing, it queues the action and returns a "pending signature" result |
| Admin authorization on new routes | Tiny | Reuse existing `authorizeRoles(['admin', 'superadmin'])` |
---
## 5. Risk notes
- **WebUSB reliability**: Some users report `Transport_Missing` errors even on Chrome when the Trezor Bridge is also installed. The fix is to uninstall Bridge and rely purely on WebUSB, or to ensure the Bridge daemon is running. We should document this in the admin setup guide.
- **Trezor Model One vs Model T vs Safe 3/5**: `@trezor/connect-web` abstracts all models. The only visible difference is whether the user confirms on buttons (Model One) or touchscreen (Model T/Safe). No code change needed.
- **Passphrase wallets**: If the admin uses a passphrase-protected hidden wallet, the passphrase must be entered in the Trezor popup. Our code does not need to handle this — it's part of the SDK popup flow.
- **Multi-admin (m-of-n)**: Out of scope for v1. The current `TrezorAccount` model stores one xpub per user. A future v2 could store multiple registered devices and require `t` signatures before `confirm-tx` succeeds. The `pending-actions` queue UI is designed to accommodate this (shows "1 of 2 signatures collected").
---
## 6. Suggested acceptance criteria (for implementation PR)
- [ ] Admin can register a Trezor and `/api/trezor/account` returns `registered: true`
- [ ] Admin can view a pending-actions queue with ≥1 sweep/release/refund awaiting signature
- [ ] Clicking "Sign with Trezor" opens the Trezor popup, displays the tx, and returns a signature
- [ ] Signed tx is broadcast from the browser and hash is reported to backend
- [ ] Backend verifies Trezor proof before confirming the action
- [ ] Break-glass toggle works and fires Telegram alarm
- [ ] Audit log captures: admin user, Trezor address, tx hash, before/after escrow state
- [ ] Without Trezor proof and with `TREZOR_SAFEKEEPING_REQUIRED=true`, release/refund/sweep is rejected

211
09 - Audits/Activity Log.md Normal file
View File

@@ -0,0 +1,211 @@
---
title: Activity Log
tags: [audit, log, append-only]
created: 2026-05-28
---
# Activity Log
Append-only log of every `git push` from `backend` and `frontend`. Newest
entries on top. Maintained by agents per the rule in `../AGENTS.md`.
---
### 2026-05-30 — frontend@9013b70, c77cf82, 8add494 — staged node-package upgrade + TS6 test fix + lint sweep
**Commits:** `8add494` `c77cf82` `9013b70`
**Touched:**
- Deps (`package.json`, `yarn.lock`): TypeScript 5→6, Jest 29→30, Tiptap 2→3 (all 11 sub-packages), i18next 25→26, react-i18next 15→17, @types/node 22→25, @types/jest 29→30, react-dropzone 14→15, react-apexcharts 1→2, mui-one-time-password-input 5→7, React 19.1→19.2, MUI 7.1→7.3 (in-range), zod 4.0→4.4. Constraints bumped to tested floors (`@mui/material ^7.3.11`, `wagmi ^2.19.5`, etc.). Version bumped 2.7.9 → 2.7.10.
- Code fixes for new types: `src/theme/with-settings/update-core.ts` (cast `currentScheme` via `Record<string,unknown>` after MUI 7.3 tightened `ColorSystemOptions`), `src/components/editor/components/code-highlight-block.tsx` (cast `NodeViewContent as='code'``'code' as 'div'` for Tiptap 3 stricter prop typing).
- Test infra: `jest.config.js` (point ts-jest at `tsconfig.test.json` explicitly, ignore TS5101/TS5011), `tsconfig.test.json` (add `rootDir: "."` and `ignoreDeprecations: "6.0"`).
- Security hygiene: `.env.local` + `.env.production` removed from tracking; added to `.gitignore`. Existing values still in git history — rotate any leaked credentials.
- Lint sweep: `yarn lint:fix` applied across 64 files in `src/` — mostly `perfectionist/sort-imports` reorders and unused-imports removals.
- Docs: `AGENTS.md` gained an "Enforced project conventions" section covering Prettier, ESLint, TypeScript, and the centralized `src/theme/` structure. `CLAUDE.md` is now a symlink → `AGENTS.md` so Claude Code reads the same rules.
- Tooling: `scripts/upgrade-packages.sh` (reusable staged-upgrade runner with snapshot + auto-rollback) and `scripts/UPGRADE-PLAN.md` (strategy + per-stage rationale) added. `.upgrade-backups/` added to `.gitignore`.
**Why:** Many runtime / dev dependencies were 37 minors behind; the audit was triggered by a request to "update all node packages without breaking the build." Did it as eight staged groups (in-range → @types → ESLint → Jest → Tiptap → i18next → misc → TypeScript), each gated by `yarn build`. Three stages were pulled back: ESLint 10 (eslint-plugin-react@7 incompatible with new context API), wagmi 3 (@coinbase/wallet-sdk declares `window.ethereum: unknown`, breaks type union with viem), MUI 7→9 (AGENTS.md pins to v7).
**Verification:** `yarn build` passes after every stage (3444s, all 57 routes). `yarn test` recovered from "45 suites fail, 0 tests run" (TS6 blocker) to 530 tests pass, 18 unrelated mock failures. `yarn lint` went 204 → 21 problems (the remaining 5 errors are pre-existing: 2× `@ts-nocheck`, 3× `no-bitwise`). Dev server (`/`, `/auth/jwt/sign-in`, `/post`, `/shop`, `/dashboard`, `/telegram`) all return 200. Manual smoke test of the Tiptap editor + wagmi connect flow is still recommended before promoting to prod.
**Linked docs updated:** none yet — `07 - Development/` should grow a "Node dependency upgrade runbook" pointing at `frontend/scripts/UPGRADE-PLAN.md` and the staged-rollback pattern. Also worth promoting the new AGENTS.md conventions section to `07 - Development/Coding Standards.md`.
---
### 2026-05-29 — backend@cdc8df1 — AMN Pay Scanner integration (retire Request Network)
**Commits:** backend `cdc8df1`, scanner `8fee27e`
**Touched:**
- Backend: `src/services/payment/adapters/amnPayAdapter.ts`, `src/routes/amnScannerWebhookRoutes.ts`, `src/services/payment/adapters/types.ts`, `src/services/payment/providerConfig.ts`, `src/app.ts`, `.env.example`, `docker-compose.dev.yml`, `docker-compose.production.yml`
- Scanner (new repo): `scanner/*.go`, `Dockerfile`, `supported-chains.json`
- Frontend: `src/actions/network-registry.ts`, `src/sections/admin/networks/networks-list-view.tsx`
**Why:** Implement AMN Pay Scanner per `PRD - Retire Request Network — In-House Payment Scanner.md`. Standalone Go microservice scans `ERC20FeeProxy` `TransferWithReferenceAndFee` events directly, eliminating RN API dependency. Supports any destination address (derived HD wallets enabled). Parallel run: RN stays active for existing payments; new payments route to scanner when `AMN_SCANNER_URL` is configured.
**Verification:** `tsc --noEmit` clean. Scanner binary builds (`go build`). Go tests pass (3/3). Frontend networks page renders scanner lag column.
**Linked docs updated:** [[07 - Development/Environment Variables]], [[PRD - Retire Request Network — In-House Payment Scanner]]
---
### 2026-05-29 — backend@7688f57 — Sweep gas strategy: PermitPull + GasTopUp signers
**Commits:** backend `7688f57`
**Touched:**
- Backend: `src/services/payment/wallets/sweepService.ts`, `__tests__/sweep-service.test.ts`, `.env.example`
**Why:** Implement hybrid two-signer sweep strategy per `PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md`. `PermitPullSweepSigner` uses EIP-2612 permit for non-BSC chains (ETH, Arbitrum, Polygon, Base) so derived addresses never need native gas. `GasTopUpSweepSigner` handles BSC by topping up BNB from a master wallet before the derived address calls `transfer()`. `getSweepSigner(chainId, tokenSymbol)` auto-selects the correct signer. Static `PERMIT_CAPABLE_TOKENS` map seeded from on-chain audit 2026-05-29.
**Verification:** `tsc --noEmit` clean. `npx jest __tests__/sweep-service.test.ts` — 31/31 pass (including 16 new tests for auto-selection and permit capability matrix).
**Linked docs updated:** [[07 - Development/Environment Variables]], [[PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up]]
---
### 2026-05-28 — deployment@4e8658d — Gatus monitoring: Docker service + config
**Commits:** deployment `1ac2e74``4e8658d`
**Touched:** `deployment/gatus/config.yaml`, `deployment/docker-compose.yml`, `deployment/.env`
**Why:** Add Gatus monitoring service to the deployment stack. Config covers backend-dev, backend-prod, frontend-dev, frontend-prod, and external deps (RN API, Chainalysis, BSC RPC). Telegram alerting configured. Service exposed via Traefik at `gatus.ch.manko.yoga`.
**Verification:** Config file validated against Gatus schema. Awaiting `docker-compose up -d gatus` on server.
**Linked docs updated:** [[08 - Operations/Gatus Monitoring - Proposed Config]]
---
### 2026-05-28 — backend@6c01a30 — Gatus monitoring: GET /api/health endpoint
**Commits:** backend `19f7eb9``44579d6``6c01a30` (2.6.48 → 2.6.49)
**Touched:**
- Backend: `src/services/health/healthCheckService.ts`, `src/services/health/index.ts`, `src/app.ts`, `__tests__/health-check.test.ts`
**Why:** Implement `GET /api/health` for Gatus monitoring. Exposes 5 checks (db, redis, rnChainRegistry, rnTokenRegistry, rnApi) in a single public endpoint. Status semantics: `ok` | `degraded` | `down` (503 when DB fails). Each check includes `latencyMs`; registry checks include counts. Rate limiter and request logging skip `/api/health`. 5 route-level unit tests cover ok/degraded/down transitions.
**Verification:** `tsc --noEmit` clean. `npx jest __tests__/health-check.test.ts` — 5/5 pass.
**Linked docs updated:** [[08 - Operations/Gatus Monitoring - Proposed Config]]
---
### 2026-05-28 — backend@19f7eb9, frontend@60ee6fb — Task #10: AML screening (Chainalysis, seller-paid, seller opt-in)
**Commits:** backend `441c8be``80ba046``19f7eb9` (2.6.46 → 2.6.47), frontend `717d5c8``b7540f5``60ee6fb` (2.6.46 → 2.6.47)
**Touched:**
- Backend: `src/services/payment/safety/amlProvider.ts`, `src/services/payment/safety/chainalysisProvider.ts`, `src/services/payment/safety/amlScreeningService.ts`, `src/services/payment/safety/transactionSafetyProvider.ts`, `src/services/payment/paymentCoordinator.ts`, `src/services/admin/amlConfigRoutes.ts`, `src/models/SellerOffer.ts`, `src/app.ts`, `.env.example`
- Frontend: `src/sections/request/components/seller-steps/step-1-send-proposal.tsx`, `src/types/marketplace.ts`
**Why:** Task #10 implementation. Chainalysis Public Sanctions API integration for seller-paid AML screening. Seller can opt-in per-offer via `requireAmlCheck` + `amlBlockOnFailure` toggles. `TransactionSafetyProvider` screens buyer source address after on-chain transfer verification. `paymentCoordinator` deducts `AML_CHECK_COST_USD` (default 0, API is free) from seller escrow on payment completion. Admin routes for AML config.
**Verification:** Frontend `tsc --noEmit` clean. Backend relevant tests pass (module resolution issues in unrelated test files).
**Linked docs updated:** [[02 - Data Models/SellerOffer]], [[03 - API Reference/Admin API]], [[04 - Flows/Escrow Flow]]
---
### 2026-05-28 — backend@441c8be, frontend@717d5c8 — Task #9: Per-chain confirmation thresholds + admin UI
**Commits:** backend `4a85737``441c8be` (2.6.47 → 2.6.48), frontend `0ebb2f1``717d5c8` (2.6.46 → 2.6.48)
**Touched:**
- Backend: `src/models/ConfigSetting.ts`, `src/services/payment/safety/confirmationThresholdService.ts`, `src/services/payment/safety/transactionSafetyProvider.ts`, `src/services/admin/confirmationThresholdRoutes.ts`, `src/services/admin/awaitingConfirmationRoutes.ts`, `src/app.ts`
- Frontend: `src/sections/admin/confirmation-thresholds/`, `src/sections/admin/payments-awaiting-confirmation/`, `src/actions/confirmation-thresholds.ts`, `src/routes/paths.ts`, `src/layouts/nav-config-dashboard.tsx`
**Why:** PRD §3 — Task #9 implementation. Runtime per-chain confirmation thresholds via `ConfigSetting` Mongo model with 30s in-memory cache. `TransactionSafetyProvider` now reads `getConfirmationThreshold(chainId)` instead of static env. Admin endpoints: `GET/PATCH /api/admin/settings/confirmation-thresholds`, `GET /api/admin/payments/awaiting-confirmation`. Frontend admin pages for threshold editing and awaiting-confirmation payment monitoring.
**Verification:** All 56 relevant backend tests green. Frontend `tsc --noEmit` clean.
**Linked docs updated:** [[03 - API Reference/Payment API]]
---
### 2026-05-28 — backend@4a85737, frontend@0ebb2f1 — Task #8: Multichain RN proxy registry + USDC/USDT support + Base fix + USDT fork test
**Commits:** backend `01b9ea0``ae17b18``4a85737` (2.6.45 → 2.6.47), frontend `0ebb2f1` (2.6.44 → 2.6.46)
**Touched:**
- Backend: `src/services/payment/requestNetwork/supportedChains.json`, `src/services/payment/requestNetwork/tokens.json`, `src/services/payment/requestNetwork/tokens.ts`, `src/services/payment/requestNetwork/proxyAddresses.ts`, `src/services/payment/requestNetwork/inHouseCheckout.ts`, `src/services/payment/requestNetwork/networkRegistryRoutes.ts`, `src/services/payment/wallets/sweepService.ts`, `src/app.ts`, `scripts/probe-rn-chains.ts`
- Frontend: `src/web3/config.ts`, `src/sections/payment/checkout/rn-in-house-checkout-view.tsx`, `src/sections/admin/networks/`, `src/app/dashboard/admin/networks/page.tsx`, `src/actions/network-registry.ts`, `src/routes/paths.ts`, `src/layouts/nav-config-dashboard.tsx`
**Why:** PRD §2 — Task #8 implementation. 5-chain registry (BSC, Arbitrum, Ethereum, Polygon, Base) with canonical RN ERC20FeeProxy addresses and per-chain USDC/USDT entries including Base. `tokens.ts` and `proxyAddresses.ts` now load from JSON files with admin reload capability. `buildInHouseCheckoutBlock` returns `unsupported_chain:<id>` for unknown chains. Frontend wagmi config expanded to include arbitrum + base. Per-chain explorer URLs in checkout view. USDT-mainnet `approve(0)` reset quirk handled in approve flow. New admin page `/dashboard/admin/networks` renders registry with reload button. New probe script `scripts/probe-rn-chains.ts` verifies proxy deployment on-chain.
**Verification:** All 58 relevant backend tests green (`rn-in-house-checkout`, `derived-destinations`, `sweep-service`, `request-template-orphan-cleanup`). Frontend `tsc --noEmit` clean.
**Linked docs updated:** [[03 - API Reference/Payment API]] (new `GET /api/admin/rn/networks` and `POST /api/admin/rn/networks/reload` endpoints)
---
### 2026-05-28 — backend@34f542e — Task #7 B: unit tests for derived-destinations + sweep-service + orphan-cleanup regression
**Commits:** backend `34f542e` (2.6.44 → 2.6.45)
**Touched:** `__tests__/derived-destinations.test.ts` (26 tests), `__tests__/sweep-service.test.ts` (18 tests), `__tests__/request-template-orphan-cleanup.test.ts` (2 tests)
**Why:** PRD item B — regression lock-in test suite for Task #7. Covers: `getDestinationFor` idempotency, E11000 race fallback, `validateXpub` rejection of xpriv/tprv/garbage, `deriveAddressAtIndex` determinism, `recordSweep` `$inc` accumulation (regression lock-in for item E), and orphan-payment cleanup provider filtering (regression lock-in for Gap 2 fix in 2.6.44).
**Verification:** All 46 tests green (`npx jest derived-destinations.test.ts sweep-service.test.ts request-template-orphan-cleanup.test.ts`).
**Linked docs updated:** [[08 - Operations/Handoff - Request Network In-House Checkout - 2026-05-28]]
---
### 2026-05-28 — backend@1889169, frontend@c44ed64 — Task #7 A verification fix: multi-checkout conversion + orphan-payment guard
**Commits:** backend `1889169` (2.6.43 → 2.6.44), frontend `c44ed64` (2.6.43 → 2.6.44)
**Touched:**
- Backend: `src/services/marketplace/RequestTemplateService.ts`
- Frontend: `src/sections/payment/checkout/rn-multi-checkout-view.tsx`
**Why:** A verification revealed two gaps: (1) `RnMultiCheckoutView.handleFinish` only navigated to payment list and never called `convertTemplatesToRequests`, so multi-seller carts never created PurchaseRequests; fixed by calling conversion with stashed cart items and navigating to the first created request. (2) Backend orphan-payment cleanup found ALL pending payments for the buyer and hard-deleted all but the first — fatal for multi-seller carts; fixed by restricting orphan query to `provider: 'shkeeper'` only so request.network payments retain their independent lifecycle.
**Verification:** Pushed to `integrate-main-into-development` on both repos — Woodpecker builds pending.
**Linked docs updated:** [[03 - API Reference/Payment API]]
---
### 2026-05-28 — backend@faf2221, frontend@022ecb6 — Task #7 derived destinations: sweep autostart, recordSweep fix, multi-seller checkout UX
**Commits:** backend `faf2221` (2.6.42 → 2.6.43), frontend `022ecb6` (2.6.42 → 2.6.43)
**Touched:**
- Backend: `src/app.ts`, `src/models/DerivedDestination.ts`, `src/models/Payment.ts`, `src/services/payment/requestNetwork/requestNetworkPayInService.ts`, `src/services/payment/wallets/derivedDestinations.ts`, `.env.example`
- Frontend: `src/sections/payment/checkout/rn-in-house-checkout-view.tsx`, `src/sections/request-template/request-template-checkout-payment.tsx`, `src/web3/components/multi-seller-provider-payment.tsx`, `src/sections/payment/checkout/rn-multi-checkout-view.tsx`, `src/app/checkout/request-network/multi/page.tsx`
**Why:** PRD items D/E/F + frontend cart-aware checkout (A). Auto-start sweep cron on boot; fix `recordSweep` to `$inc` totalSwept instead of `$setOnInsert`; widen Payment unique index to include `sellerOfferId` for multi-seller carts; add multi-seller checkout wrapper and wire into template + request flows.
**Verification:** Pushed to `integrate-main-into-development` on both repos — Woodpecker builds pending.
**Linked docs updated:** [[03 - API Reference/Payment API]] (derived-destination endpoints)
---
### 2026-05-28 — backend@e46be98, frontend@af77b3c — add nick-doc sync rule + version bumps
**Commits:** backend `e46be98` (2.6.24 → 2.6.25), frontend `af77b3c` (2.6.25 → 2.6.26)
**Touched:** `backend/AGENTS.md`, `frontend/AGENTS.md` (new), both `package.json` +
`package-lock.json`
**Why:** Establish a mandatory rule that every code push must be followed by a
nick-doc Activity Log entry (and relevant section updates) so the vault never
falls behind the code. Frontend AGENTS.md created from scratch (was missing).
**Verification:** Pushed to `integrate-main-into-development` on both repos —
Woodpecker builds pending.
**Linked docs updated:** This vault's `AGENTS.md` updated with the same rule.
**Note:** Backend (2.6.25) and frontend (2.6.26) are intentionally one patch
apart — backend was a version behind before this session. Should be re-aligned
on the next paired bump.
---
### 2026-05-28 — frontend@9d4aa37 — fix 429 request storm on template SWR hooks
**Commits:** `9d4aa37`
**Touched:** `src/actions/request-template.ts`
**Why:** Production browser showed repeated 429 (Too Many Requests) on
`/api/marketplace/request-templates/sellers`. Default SWR config was
revalidating on focus/reconnect and retrying on errors, making backend
rate-limit recover impossible without a restart.
**Verification:** Pushed, awaiting Woodpecker build. Visual confirmation on
dev.amn.gg after deploy.
**Linked docs updated:** none yet — SWR pattern should be promoted to
`07 - Development/Coding Standards.md` in a follow-up.
---
### 2026-05-28 — frontend@6c89444 — improve request template form debug feedback
**Commits:** `6c89444`
**Touched:** `src/sections/request-template/request-template-new-edit-form.tsx`
**Why:** Users could not tell why "ایجاد قالب" failed — validation errors
silently blocked submission, API errors collapsed to generic "خطایی رخ داده
است!", and the "انتشار" Switch in renderActions was visual-only.
**Verification:** Type-check passes via Docker build in prior session; manual
browser test pending.
**Linked docs updated:** none.
---
### 2026-05-27 — frontend@8c0f14d, ad498f4, f3a3c9d, bb72a66 — unblock 2.6.19 Docker build
**Commits:** `bb72a66` `f3a3c9d` `ad498f4` `8c0f14d`
**Touched:** `src/sections/request-template/request-template-checkout-payment.tsx`,
`src/web3/components/wallet-selector.tsx`, `tsconfig.json`, `src/types/payment.ts`
**Why:** Docker build was failing on TypeScript compilation after the
wallet-support + test-payment feature merge. Four distinct errors fixed:
User type uses `_id` not `id`; wallet-selector imported non-existent
`@/components/ui/dialog`; `@/*` path alias missing from tsconfig; IPayment
metadata type didn't allow test-payment fields.
**Verification:** Local `docker build` succeeded — image
`escrow-frontend:2.6.19` created.
**Linked docs updated:** none — should add SWR + UI library notes to
`07 - Development/Coding Standards.md`.
---
<!-- Add new entries above this line. Newest at top. -->

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
---
title: Full Codebase Audit — 2026-05-30
tags: [audit, index, security, logic, performance]
created: 2026-05-30
status: open
---
# Full Codebase Audit — 2026-05-30
Full-system audit across all three repos (frontend, backend, scanner) triggered as a periodic health pass. 134 findings across security, logic, performance, and supply-chain dimensions. 49 no-brainers were applied automatically; 1 was skipped (requires new persistence layer); 80 decision items were queued for human review.
---
## Findings Summary by Severity and Dimension
| Severity | Security | Logic | Performance | Supply-Chain | Total |
|----------|----------|-------|-------------|--------------|-------|
| Critical | 3 | 2 | 0 | 1 | 6 |
| High | 18 | 12 | 8 | 4 | 42 |
| Medium | 14 | 12 | 8 | 6 | 40 |
| Low | 10 | 6 | 10 | 20 | 46 |
| **Total** | **45** | **32** | **26** | **31** | **134** |
### By Repo
| Repo | Findings | No-Brainers Applied | Skipped | Decision Items |
|------|----------|---------------------|---------|----------------|
| frontend | 49 | 18 (NB-1 NB-17, NB-49) | 0 | 31 (DEC-121, DEC-7480) |
| backend | 55 | 21 (NB-18 NB-38) | 1 (NB-27) | 33 (DEC-2256) |
| scanner | 30 | 10 (NB-39 NB-48) | 0 | 20 (DEC-5773) |
---
## Systemic Themes
Eight root-cause patterns cut across most findings. Addressing these themes eliminates whole clusters at once.
### 1. Missing Authorization on Payment and Admin Endpoints (Broken Access Control)
Routes are gated only by `authenticateToken`/`AuthGuard` with no role or ownership check. Payment status writes, exports, stats, user-payment listings, file deletion, delivery updates, offer selection, dispute evidence, and the entire admin UI tree all trust authentication alone. **Root fix:** a shared `requireAdmin` middleware + ownership-check helper + centralized status-transition validator applied consistently.
### 2. Payment Status State Machine Is Inconsistent and Corruptible
Non-enum statuses (`'released'`, `'funded'`) are written and silently dropped, the provider enum omits `'shkeeper'`, transition guards check fields never set (`escrowState:'funded'`), the transition map omits `'in_negotiation'`, and amount-mismatch is checked after side-effects commit. **Root cause:** schema enums and state machine drifted from the code that writes them.
### 3. Secrets Committed to the Repo and Baked into Images
Telegram bot token, Resend SMTP key, Google secret, JWT secret, admin password, Alchemy keys, and RN secrets appear across `.env.example`, `.env.development`, `.gitleaks.toml`, Dockerfiles, and committed scripts — and `.dockerignore` whitelists `.env.development` into prod images. **Root fix:** placeholder all committed files, remove env files from images, inject at runtime, rotate every exposed credential.
### 4. Test/Debug Bypasses Reachable in Production
Test-payment mode, `force-verify-user`, RN test-webhook signature bypass, the debug panel, and console-suppression hacks all rely on weak runtime `NODE_ENV` checks (or none). **Root fix:** gate on `NODE_ENV` at registration/build time; never honour bypass flags in production.
### 5. N+1 Queries, Unbounded Fan-Out, and Chatty Polling
Per-row DB lookups in `getPurchaseRequestsByBuyer` and `getReferrals`, unbounded notification/seller fan-out, redundant polling alongside sockets, full-collection loads, and per-intent HTTP fan-out in the scanner. **Root fix:** batch with `$in`/aggregation, bound concurrency, replace redundant polling with socket-driven or visibility-gated updates.
### 6. Float Math and Weak Randomness in Money/Crypto Paths
USDT wei conversion via IEEE-754 floats risks under-payment; verification codes use `Math.random` instead of a CSPRNG. **Root fix:** use `parseUnits` for token amounts and `crypto.randomInt` for codes (both already available in the codebase).
### 7. Unhardened Outbound HTTP and Webhook Handling (SSRF / OOM / Retry Leaks)
Scanner accepts arbitrary `callbackUrl` (SSRF), follows third-party `next`-URLs unvalidated, reads RPC/API bodies without size limits (OOM), overrides confirmation thresholds, and spawns unbounded sleeping retry goroutines. **Root fix:** URL allowlisting + private-range blocking at dial time, `io.LimitReader` caps, threshold floors, bounded persisted retry queues.
### 8. CI/CD Supply-Chain Hygiene Gaps
Floating/unpinned images, missing lint/type/test/audit gates on production and manual pipelines, privileged buildx, dual lockfiles, no `engines` pin, and untested manual builds. **Root fix:** digest-pin all CI images, enforce quality gate on every pipeline, unify lockfiles, add audit/vuln scanning.
---
## No-Brainers Applied (49 fixes)
All 49 no-brainers were applied. NB-13/NB-14: lockfile not regenerated (no `yarn install` run per instructions — leave uncommitted for human review). NB-7: requires backend to expose `GET /chat/unread-count` returning `{ data: { count: number } }`. NB-29: depends on DEC-32 outcome; applied `status:'completed'` as interim until enum decision is made.
| ID | Repo | Title | Files |
|----|------|-------|-------|
| NB-1 | frontend | USDT amount-to-wei uses floating-point arithmetic | `src/web3/context/action.ts` |
| NB-2 | frontend | Email verification logs full form data including password | `src/auth/view/jwt/jwt-verify-view.tsx` |
| NB-3 | frontend | Hardcoded Telegram bot ID fallback in widget loader | `src/auth/utils/telegram-login-widget.ts` |
| NB-4 | frontend | releasePayment returns fake success with hardcoded tx hash | `src/actions/payment.ts` |
| NB-5 | frontend | signUp/verifyEmailWithCode bypass StorageUtils.safeSet | `src/auth/context/jwt/action.ts` |
| NB-6 | frontend | Redundant 30s polling on buyer request details page | `src/sections/request/view/buyer/buyer-request-details-view.tsx` |
| NB-7 | frontend | getUnreadCount fetches entire conversation list | `src/actions/chat.ts`, `src/lib/axios.ts` |
| NB-8 | frontend | Debug new Error().stack capture on every step-change | `src/sections/request/view/buyer/buyer-request-details-view.tsx` |
| NB-9 | frontend | transformMessage logs two info calls per message | `src/actions/chat.ts` |
| NB-10 | frontend | Alchemy API keys hardcoded as Dockerfile ARG defaults | `Dockerfile` |
| NB-11 | frontend | Escrow wallet address hardcoded across multiple files | `src/web3/decentralizedPayment.ts`, `step-6-buyer-confirmed.tsx`, `manual-payout.tsx` |
| NB-12 | frontend | NEXT_PUBLIC_MAPBOX_API_KEY missing from Dockerfile ARGs/docs | `Dockerfile` |
| NB-13 | frontend | google-auth-library and @google-cloud/local-auth unused | `package.json` |
| NB-14 | frontend | @depay/widgets unused dependency | `package.json` |
| NB-15 | frontend | MockedUser (demo@minimals.cc) rendered in production nav | `src/layouts/components/nav-upgrade.tsx` |
| NB-16 | frontend | WEB3_PROVIDER_URL declared but never used | `src/global-config.ts` |
| NB-17 | frontend | google-oauth.ts.backup committed to source tree | `src/auth/services/google-oauth.ts.backup` |
| NB-18 | backend | Verification/reset codes logged to server console | `src/services/auth/authController.ts`, `src/services/delivery/DeliveryService.ts` |
| NB-19 | backend | Verification code uses Math.random() | `src/services/auth/authService.ts` |
| NB-20 | backend | Admin password hardcoded fallback in init-admin.ts | `src/infrastructure/database/init-admin.ts` |
| NB-21 | backend | force-verify-user route registered unconditionally | `src/services/auth/authRoutes.ts` |
| NB-22 | backend | getUserPayments queries non-existent 'userId' field | `src/services/payment/paymentService.ts` |
| NB-23 | backend | getPaymentStats sums object-typed amount field | `src/services/payment/paymentService.ts` |
| NB-24 | backend | GET /api/payment/export endpoints lack admin guard | `src/services/payment/paymentControllerRoutes.ts` |
| NB-25 | backend | getUserPayments route lacks ownership check (IDOR) | `src/services/payment/paymentControllerRoutes.ts` |
| NB-26 | backend | GET /api/files/stats missing admin guard | `src/services/file/fileRoutes.ts` |
| NB-28 | backend | updateDeliveryInfo does not enforce seller ownership | `src/services/marketplace/marketplaceController.ts` |
| NB-29 | backend | payout/confirm and release/confirm set non-enum 'released' status | `src/services/payment/requestNetwork/requestNetworkRoutes.ts` |
| NB-30 | backend | N+1 per-request Payment lookup in getPurchaseRequestsByBuyer | `src/services/marketplace/PurchaseRequestService.ts` |
| NB-31 | backend | Full unpaginated load in getPayments admin endpoint | `src/services/marketplace/marketplaceController.ts` |
| NB-32 | backend | 13 sequential countDocuments in getCollectionStats | `src/services/admin/dataCleanupService.ts` |
| NB-33 | backend | Real credentials committed in tracked .env.example | `.env.example` |
| NB-34 | backend | Dockerfile.dev runs --frozen-lockfile before copying yarn.lock | `Dockerfile.dev` |
| NB-35 | backend | Deprecated npm 'crypto' shim in production deps | `package.json` |
| NB-36 | backend | body-parser redundant with Express 5 | `package.json` |
| NB-37 | backend | manual.yml CI missing typecheck gate | `.woodpecker/manual.yml` |
| NB-38 | backend | No engines field / .nvmrc for Node version | `package.json`, `.nvmrc` |
| NB-39 | scanner | Scanner Dockerfile runs as root (no USER) | `Dockerfile` |
| NB-40 | scanner | cleanup.yml uses alpine:latest | `.woodpecker/cleanup.yml` |
| NB-41 | scanner | scanner buildx plugin not pinned | `.woodpecker/development.yml`, `.woodpecker/manual.yml`, `.woodpecker/production.yml` |
| NB-42 | scanner | Scanner RPC/API bodies read without size limit | `chain.go`, `tron_chain.go`, `ton_chain.go` |
| NB-43 | scanner | Scanner manual.yml has no test step | `.woodpecker/manual.yml` |
| NB-44 | scanner | No govulncheck/gosec in scanner CI | `.woodpecker/development.yml`, `.woodpecker/production.yml` |
| NB-45 | scanner | No RPC_TRON/RPC_TON override env vars | `config.go` |
| NB-46 | scanner | EVM scan lag warning uses reorgBuf-adjusted checkpoint | `chain.go` |
| NB-47 | scanner | handleScannerStatus loads full intent rows to count pending | `api.go`, `intent.go` |
| NB-48 | scanner | SQLite no connection pool limit set | `intent.go` |
| NB-49 | frontend | Admin route polling paused when tab hidden | `payments-awaiting-confirmation-list-view.tsx` |
### Skipped No-Brainers
| ID | Reason | Issue Filed |
|----|--------|-------------|
| NB-27 (DELETE /api/files/delete ownership check) | `fileService.deleteFile()` is a pure filesystem path operation with no DB ownership record — no `File` model, no `createdBy`/`owner` field stored anywhere. Adding an ownership check requires creating a new persistence layer, which is a larger-than-mechanical change. | [[ISSUE-055-delete-api-files-delete-has-no-ownership-check-requires-new-pe|ISSUE-055]] |
---
## Decision Queue (80 items)
These items require human judgment before implementation. Each has a corresponding issue file.
### Critical
| Issue | Title | Repo | Recommendation |
|-------|-------|------|----------------|
| [[ISSUE-056-backend-verifypayment-and-paymentcallback-routes-unauthenticat|ISSUE-056]] | verifyPayment and paymentCallback routes unauthenticated | backend | Auth + HMAC on callback; remove isWeb3Payment bypass |
### High
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-057-frontend-admin-ui-routes-lack-role-based-authorization-guard|ISSUE-057]] | Admin UI routes lack role-based authorization guard | frontend |
| [[ISSUE-058-frontend-test-payment-mode-enablable-in-production-via-env-var|ISSUE-058]] | Test payment mode enablable in production via NEXT_PUBLIC env var | frontend |
| [[ISSUE-059-frontend-auth-provider-clears-tokens-on-any-non-403-error|ISSUE-059]] | Auth provider clears tokens on any non-403 error including network failures | frontend |
| [[ISSUE-060-frontend-contacts-popover-reads-userid-from-non-existent-local|ISSUE-060]] | contacts-popover reads userId from non-existent localStorage 'user' key | frontend |
| [[ISSUE-061-frontend-socket-context-helpers-accumulate-listeners-without-d|ISSUE-061]] | Socket context helpers accumulate listeners without dedup | frontend |
| [[ISSUE-062-backend-payment-update-routes-lack-ownership-role-guards|ISSUE-062]] | Backend payment update routes lack ownership/role guards | backend |
| [[ISSUE-063-backend-legacy-marketplace-patch-payments-id-lets-any-user-set|ISSUE-063]] | Legacy marketplace PATCH /payments/:id lets buyer/seller set any status | backend |
| [[ISSUE-064-backend-request-network-allow-test-webhooks-bypasses-signature|ISSUE-064]] | REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS bypasses signature verification | backend |
| [[ISSUE-065-backend-rn-webhook-advances-purchaserequest-to-non-existent-fu|ISSUE-065]] | RN webhook advances PurchaseRequest to non-existent 'funded' status | backend |
| [[ISSUE-066-backend-payout-and-release-confirm-set-non-enum-status|ISSUE-066]] | payout/confirm and release/confirm set non-enum status 'released' | backend |
| [[ISSUE-067-backend-amount-mismatch-check-runs-after-payment-saved-and-offe|ISSUE-067]] | amount-mismatch check runs after payment saved and offers accepted | backend |
| [[ISSUE-068-backend-datacleanuservice-deletes-payments-without-provider-sco|ISSUE-068]] | dataCleanupService deletes Payments without provider scoping | backend |
| [[ISSUE-069-backend-cleanupoldpendingpayments-deletes-pending-rn-payments-m|ISSUE-069]] | cleanupOldPendingPayments deletes pending RN payments mid-flow | backend |
| [[ISSUE-070-backend-notifyallsellersaboutnewrequest-unbounded-fan-out|ISSUE-070]] | notifyAllSellersAboutNewRequest unbounded fan-out | backend |
| [[ISSUE-071-backend-getreferrals-n-plus-1-purchaserequest-and-pointtransac|ISSUE-071]] | getReferrals N+1 (PurchaseRequest + PointTransaction per referral) | backend |
| [[ISSUE-072-backend-chat-messages-stored-as-embedded-array-unbounded-growth|ISSUE-072]] | Chat messages stored as embedded array (unbounded document growth) | backend |
| [[ISSUE-073-backend-payment-provider-enum-missing-shkeeper|ISSUE-073]] | Payment provider enum missing 'shkeeper' | backend |
| [[ISSUE-074-backend-env-development-committed-with-live-telegram-and-smtp-s|ISSUE-074]] | Backend Telegram bot token + SMTP key committed in .env.development | backend |
| [[ISSUE-075-backend-dockerignore-whitelists-env-development-into-prod-image|ISSUE-075]] | .dockerignore whitelists .env.development into prod image | backend |
| [[ISSUE-076-scanner-ssrf-via-unvalidated-callbackurl|ISSUE-076]] | Scanner: SSRF via unvalidated callbackUrl | scanner |
| [[ISSUE-077-scanner-caller-can-override-confirmation-threshold-down-to-1|ISSUE-077]] | Scanner: caller can override confirmation threshold down to 1 | scanner |
| [[ISSUE-078-scanner-idempotency-path-ignores-mismatched-parameters|ISSUE-078]] | Scanner: idempotency path ignores mismatched parameters | scanner |
| [[ISSUE-079-frontend-telegram-bot-token-committed-in-gitleaks-toml-allowli|ISSUE-079]] | Frontend: Telegram bot token committed in .gitleaks.toml allowlist | frontend |
### Medium
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-080-frontend-open-redirect-via-unvalidated-returnto-in-guestguard|ISSUE-080]] | Open redirect via unvalidated returnTo in GuestGuard | frontend |
| [[ISSUE-081-frontend-tokens-stored-in-localstorage-xss-accessible|ISSUE-081]] | Tokens stored in localStorage (XSS-accessible) | frontend |
| [[ISSUE-082-frontend-wallet-ownership-signature-verification-is-a-no-op|ISSUE-082]] | Wallet ownership signature verification is a no-op on frontend | frontend |
| [[ISSUE-083-frontend-no-content-security-policy-header-in-next-config|ISSUE-083]] | No Content-Security-Policy header in Next.js config | frontend |
| [[ISSUE-084-frontend-console-error-warn-suppression-masks-prod-errors|ISSUE-084]] | console.error/warn suppression masks prod errors | frontend |
| [[ISSUE-085-frontend-token-refresh-queue-dispatches-with-undefined-authori|ISSUE-085]] | Token refresh queue dispatches with undefined Authorization | frontend |
| [[ISSUE-086-frontend-paymentdetailsview-status-dropdown-exposed-to-all-use|ISSUE-086]] | PaymentDetailsView status dropdown exposed to all users | frontend |
| [[ISSUE-087-frontend-getpaymentstatus-and-checkpaymentstatus-hit-different|ISSUE-087]] | getPaymentStatus and checkPaymentStatus hit different endpoints | frontend |
| [[ISSUE-088-frontend-adminwalletpayout-falls-back-to-literal-admin-string|ISSUE-088]] | adminWalletPayout falls back to literal 'admin' adminUserId | frontend |
| [[ISSUE-089-frontend-admin-payments-awaiting-confirmation-polls-every-12s|ISSUE-089]] | Admin payments-awaiting-confirmation polls every 12s unconditionally | frontend |
| [[ISSUE-090-frontend-chat-views-re-fetch-full-conversation-on-every-new-me|ISSUE-090]] | Chat views re-fetch full conversation on every new-message event | frontend |
| [[ISSUE-091-frontend-dual-socket-connections-socketprovider-and-socketserv|ISSUE-091]] | Dual socket connections (SocketProvider + socketService singleton) | frontend |
| [[ISSUE-092-backend-jwt-refresh-and-access-tokens-share-same-secret|ISSUE-092]] | JWT refresh and access tokens share the same secret; middleware skips type check | backend |
| [[ISSUE-093-backend-addevidence-no-participant-ownership-check-on-disputes|ISSUE-093]] | addEvidence: no participant ownership check on disputes | backend |
| [[ISSUE-094-backend-selectoffer-does-not-verify-buyer-owns-purchase-request|ISSUE-094]] | selectOffer does not verify buyer owns the purchase request | backend |
| [[ISSUE-095-backend-getuserstats-no-ownership-admin-check-idor|ISSUE-095]] | getUserStats: no ownership/admin check (IDOR) | backend |
| [[ISSUE-096-backend-validatestatustransition-requires-escrowstate-funded-n|ISSUE-096]] | validateStatusTransition requires escrowState 'funded' never set on completed payments | backend |
| [[ISSUE-097-backend-validtransitions-map-missing-in-negotiation-key|ISSUE-097]] | validTransitions map missing 'in_negotiation' key | backend |
| [[ISSUE-098-backend-in-memory-seendeliveryids-resets-on-restart|ISSUE-098]] | validateStatusTransition: in-memory seenDeliveryIds resets on restart | backend |
| [[ISSUE-099-backend-on-demand-rn-reconciliation-in-getpaymentbyid-can-race|ISSUE-099]] | On-demand RN reconciliation in getPaymentById can race | backend |
| [[ISSUE-100-backend-updatepurchaserequest-does-findbyid-then-findbyidandupd|ISSUE-100]] | updatePurchaseRequest does findById then findByIdAndUpdate | backend |
| [[ISSUE-101-backend-config-loads-env-development-unconditionally|ISSUE-101]] | Backend config loads .env.development unconditionally | backend |
| [[ISSUE-102-backend-14-high-severity-npm-vulns-no-audit-step-in-ci|ISSUE-102]] | 14 high-severity npm vulns, no audit step in CI | backend |
| [[ISSUE-103-backend-react-react-dom-in-backend-production-dependencies|ISSUE-103]] | react/react-dom in backend production dependencies | backend |
| [[ISSUE-104-backend-bcrypt-native-addon-alongside-used-bcryptjs|ISSUE-104]] | bcrypt native addon present alongside used bcryptjs | backend |
| [[ISSUE-105-backend-no-startup-validation-of-required-env-vars|ISSUE-105]] | No startup validation of required env vars | backend |
| [[ISSUE-106-backend-dual-lockfiles-yarn-lock-and-package-lock-json-diverge|ISSUE-106]] | Dual lockfiles (yarn.lock + package-lock.json) diverge | backend |
| [[ISSUE-107-scanner-tronGrid-pagination-next-url-used-unvalidated|ISSUE-107]] | Scanner: TronGrid pagination next-URL used unvalidated | scanner |
| [[ISSUE-108-scanner-unauthenticated-startup-when-scanner-api-key-unset|ISSUE-108]] | Scanner: unauthenticated startup when SCANNER_API_KEY unset | scanner |
| [[ISSUE-109-scanner-tron-lag-metric-reported-in-ms-not-blocks|ISSUE-109]] | Scanner: Tron lag metric reported in ms, not blocks | scanner |
| [[ISSUE-110-scanner-ton-worker-on-http-fan-out-per-scan-cycle|ISSUE-110]] | Scanner: TON worker O(N) HTTP fan-out per scan cycle | scanner |
| [[ISSUE-111-scanner-deliverwebhook-goroutines-use-blocking-time-sleep|ISSUE-111]] | Scanner: deliverWebhook goroutines use blocking time.Sleep (leak risk) | scanner |
| [[ISSUE-112-scanner-unbounded-goroutine-fan-out-for-webhook-retries|ISSUE-112]] | Scanner: unbounded goroutine fan-out for webhook retries | scanner |
| [[ISSUE-113-scanner-rpc-response-bodies-read-without-size-limit-oom|ISSUE-113]] | Scanner/backend: RPC response bodies read without size limit (OOM) | scanner |
| [[ISSUE-114-frontend-walletconnect-google-client-ids-hardcoded-dockerfile|ISSUE-114]] | Frontend: WalletConnect/Google client IDs hardcoded as Dockerfile ARG defaults | frontend |
| [[ISSUE-115-frontend-real-plaintext-credentials-in-committed-scripts|ISSUE-115]] | Frontend: real plaintext credentials in committed scripts | frontend |
| [[ISSUE-116-frontend-backend-scanner-ci-images-not-pinned-to-digests|ISSUE-116]] | Frontend/scanner/backend: CI images not pinned to digests | frontend |
| [[ISSUE-117-frontend-backend-scanner-production-manual-ci-pipelines-lack-g|ISSUE-117]] | Frontend/scanner/backend: production/manual CI pipelines lack lint/type/test/audit gates | frontend |
### Low
| Issue | Title | Repo |
|-------|-------|------|
| [[ISSUE-118-frontend-notification-title-rendered-via-dangerouslysetinnerht|ISSUE-118]] | Notification title rendered via dangerouslySetInnerHTML | frontend |
| [[ISSUE-119-frontend-telegramdebugpanel-exposed-in-production-via-url-flag|ISSUE-119]] | TelegramDebugPanel exposed in production via URL/localStorage flag | frontend |
| [[ISSUE-120-frontend-50ms-setinterval-console-suppression-script-in-root-l|ISSUE-120]] | 50ms setInterval console-suppression script in root layout | frontend |
| [[ISSUE-121-frontend-transferfunds-and-createpayment-post-to-same-endpoint|ISSUE-121]] | transferFunds and createPayment POST to the same endpoint | frontend |
| [[ISSUE-122-backend-missing-compound-index-for-seller-visibility-purchase-r|ISSUE-122]] | Missing compound index for seller-visibility purchase-request query | backend |
| [[ISSUE-123-backend-notification-unread-count-chatty-db-access|ISSUE-123]] | Notification unread-count chatty DB access | backend |
| [[ISSUE-124-backend-per-seller-socket-emit-loop-in-updatepurchaserequeststatu|ISSUE-124]] | Per-seller socket emit loop in updatePurchaseRequestStatus | backend |
| [[ISSUE-125-backend-getcategorypath-unbounded-sequential-findbyid-loop|ISSUE-125]] | getCategoryPath unbounded sequential findById loop | backend |
| [[ISSUE-126-backend-getuserpoints-writes-full-user-document-on-read|ISSUE-126]] | getUserPoints writes full User document on read when fields missing | backend |
| [[ISSUE-127-scanner-get-intents-id-exposes-salt-and-callbackurl|ISSUE-127]] | Scanner: GET /intents/:id exposes salt and callbackUrl | scanner |
| [[ISSUE-128-scanner-post-intents-returns-200-instead-of-201|ISSUE-128]] | Scanner: POST /intents returns 200 instead of 201 | scanner |
| [[ISSUE-129-scanner-ton-processTransfer-doesnt-verify-jettonmasteraddress|ISSUE-129]] | Scanner: TON processTransfer doesn't verify JettonMasterAddress vs intent.TokenAddress | scanner |
| [[ISSUE-130-scanner-config-getchaingettokengetrpc-on-linear-scans|ISSUE-130]] | Scanner: Config.GetChain/GetToken/GetRPC O(N) linear scans | scanner |
| [[ISSUE-131-scanner-tron-ton-workers-dont-share-http-transport|ISSUE-131]] | Scanner: Tron/TON workers don't share HTTP transport | scanner |
| [[ISSUE-132-scanner-evm-checkpoint-saved-every-2000-block-chunk|ISSUE-132]] | Scanner: EVM checkpoint saved every 2000-block chunk | scanner |
| [[ISSUE-133-scanner-ci-buildx-steps-run-privileged-true|ISSUE-133]] | Scanner: CI buildx steps run privileged: true | scanner |
| [[ISSUE-134-frontend-sentry-source-map-upload-configured-but-no-auth-token|ISSUE-134]] | Frontend: Sentry source-map upload configured but no auth token injected | frontend |
| [[ISSUE-135-backend-uploads-directory-served-without-authentication|ISSUE-135]] | Backend uploads directory served without authentication | backend |
---
## Documentation Gaps Identified (Doc Sync)
The following gaps were identified but not filled during this audit pass. They should be tracked as separate doc tasks:
- **Frontend:** Admin dashboard sub-pages (confirmation-thresholds, networks, payments-awaiting-confirmation, trezor) missing from Admin API doc.
- **Frontend:** Trezor registration and break-glass UI (commit c9ce345) not reflected in Trezor API or Trezor Safekeeping Flow docs.
- **Frontend:** Cloudflare Turnstile/CAPTCHA behavior (3 failed logins) not documented in Authentication Flow or Authentication API docs.
- **Frontend:** AMN Pay Scanner lag column and per-row probe button have no dedicated flow or operations doc.
- **Frontend:** Telegram startup notification (TG_NOTIFY_BOT_TOKEN) not in Operations/Environment Variables doc.
- **Frontend:** Amaneh UI variant toggle — state key and exact behavior not fully described in Settings & Theming.
- **Frontend:** `productLink` made truly optional; `deliveryType` required marker dropped — Purchase Request Flow wizard narrative needs update.
- **Backend:** Sweep signer strategy (PermitPullSweepSigner + GasTopUpSweepSigner) has no operations runbook.
- **Backend:** Native token sweep (BNB/ETH to derived destinations) not reflected in Payment API or sweep operations runbook.
- **Backend:** AML screening (OFAC SDN provider) has no dedicated flow doc covering when screening fires, seller opt-in, fee deduction.
- **Backend:** GET /api/health response field names not verified against live `healthCheckService` output.
- **Backend:** RequestTemplate budget currency restriction (USDT/USDC only) not reflected in Marketplace API or RequestTemplate model docs.
- **Backend:** Sweep integration tests (Anvil + INTEGRATION_TEST=1) not covered in Testing.md.
- **Backend:** Telegram startup notification (app startup `tgNotify`) not in Monitoring.md.
- **Backend:** AMN Pay Scanner adapter internals (amnPayAdapter, amnScannerWebhookRoutes) have no doc.
- **Backend:** New env vars (OFAC_SDN_URL, TURNSTILE_SECRET_KEY, TURNSTILE_SITE_KEY, AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET) may not be in Environment Variables doc.
- **Backend:** Seller Offer Flow does not reflect selectedOfferId persistence fix and atomic offer rejection on payment.
- **Backend:** ISSUE-021 (POST /api/marketplace/offers/:id/withdraw) should be marked resolved (implemented in commit 3e47713).
- **Scanner:** No doc for CI pipeline structure (.woodpecker/ steps, secrets, image push flow).
- **Scanner:** No doc for test suite (chain_validate_test.go / reference_test.go / tron_chain_test.go) and how to extend it.
- **Scanner:** Multi-chain reorg edge cases and exact ReorgBuffer formula not in troubleshooting doc.
- **Scanner:** TON scaling limitation (O(pending intents) API calls per cycle) noted but no mitigation/batching design documented.
- **Scanner:** RN proxy address discrepancy in supported-chains.json (ETH v0.1.0 vs v0.2.0) not documented.
---
## References
- [[Security Audit - 2026-05-24]]
- [[Logic Audit - 2026-05-24]]
- [[Performance Audit - 2026-05-24]]
- [[Doc vs Code Audit Report - 2026-05-29]]

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,39 @@ This documentation workspace uses Taskmaster as the source of truth for agent wo
## Repository Rules ## Repository Rules
- Repository-wide operating rules live in `../RTK.md`; follow them in addition to this file. - Repository-wide operating rules live in `RTK.md` at this vault root; 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. - 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. - 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. - In the final response, mention version/build bumps and verification commands when they were part of the work.
## Sync-From-Code Rule (MANDATORY)
Whenever an agent finishes a commit-push in `../backend` or `../frontend`, this
vault MUST be updated **in the same working session**:
1. Add a new entry to `09 - Audits/Activity Log.md` — newest at the top.
Use this template:
```markdown
### YYYY-MM-DD — <repo>@<short-sha> — <one-line summary>
**Commits:** `<sha1>` `<sha2>` …
**Touched:** path/one.ts, path/two.tsx
**Why:** <motivation — bug, feature, PRD link, incident #>
**Verification:** <build status, smoke result, manual check>
**Linked docs updated:** [[03 - API Reference/Foo]], [[04 - Flows/Bar]]
```
2. If the change affects API surface, data models, flows, architecture, ops,
env vars, or design, update the matching numbered section in this vault
in addition to the Activity Log entry (do not just log it).
3. Commit with message: `docs: sync from <repo> <short-sha> — <summary>` and
push to `origin/main`.
The companion `AGENTS.md` files at `../backend/AGENTS.md` and
`../frontend/AGENTS.md` carry the same rule from the code-side.
## 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.
@@ -35,5 +63,5 @@ Do not hand-edit `.taskmaster/tasks/tasks.json` or generated task markdown files
- Treat pending Taskmaster tasks as the prioritized backlog. - Treat pending Taskmaster tasks as the prioritized backlog.
- Respect task dependencies shown by `task-master next` and `task-master show`. - Respect task dependencies shown by `task-master next` and `task-master show`.
- Update the relevant task whenever edits, findings, verification results, or blockers materially change the state of the work. - Update the relevant task whenever edits, findings, verification results, or blockers materially change the state of the work.
- Before the final response, confirm that Taskmaster reflects the current task status. - Before the final response, confirm that Taskmaster reflects the current task status AND that the Activity Log has the latest push entry (if a push happened in this session).
- If `task-master` is unavailable, mention that in the final response and summarize the Taskmaster update that should be applied manually. - If `task-master` is unavailable, mention that in the final response and summarize the Taskmaster update that should be applied manually.

148
Amn Roadmap.canvas Normal file
View File

@@ -0,0 +1,148 @@
{
"nodes":[
{"id":"n1","type":"text","text":"# 🟧 Amn — Crypto-Escrow Marketplace","x":1555,"y":0,"width":460,"height":90,"color":"6"},
{"id":"n2","type":"text","text":"## Auth & Identity","x":0,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n3","type":"text","text":"Email + Password (JWT)","x":0,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n4","type":"text","text":"Passkey / WebAuthn","x":0,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n5","type":"text","text":"Google OAuth","x":0,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n6","type":"text","text":"Telegram first-class auth","x":0,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n7","type":"text","text":"Email verification codes","x":0,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n8","type":"text","text":"Password reset","x":0,"y":744,"width":270,"height":56,"color":"4"},
{"id":"n9","type":"text","text":"Refresh token rotation","x":0,"y":822,"width":270,"height":56,"color":"4"},
{"id":"n10","type":"text","text":"Roles: admin / buyer / seller","x":0,"y":900,"width":270,"height":56,"color":"4"},
{"id":"n11","type":"text","text":"## Marketplace","x":330,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n12","type":"text","text":"Purchase Requests","x":330,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n13","type":"text","text":"Seller Offers","x":330,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n14","type":"text","text":"Request Templates","x":330,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n15","type":"text","text":"Negotiation","x":330,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n16","type":"text","text":"Categories","x":330,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n17","type":"text","text":"Reviews & Ratings","x":330,"y":744,"width":270,"height":56,"color":"4"},
{"id":"n18","type":"text","text":"## Escrow","x":660,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n19","type":"text","text":"Escrow state machine","x":660,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n20","type":"text","text":"Funds ledger","x":660,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n21","type":"text","text":"Delivery confirmation","x":660,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n22","type":"text","text":"Dispute hold gate","x":660,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n23","type":"text","text":"Release / Payout","x":660,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n24","type":"text","text":"Refund orchestration","x":660,"y":744,"width":270,"height":56,"color":"4"},
{"id":"n25","type":"text","text":"## Payments","x":990,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n26","type":"text","text":"SHKeeper invoicing","x":990,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n27","type":"text","text":"Request Network pay-in","x":990,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n28","type":"text","text":"Decentralized (Wagmi + DePay)","x":990,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n29","type":"text","text":"Provider-neutral adapter","x":990,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n30","type":"text","text":"Signed webhook intake","x":990,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n31","type":"text","text":"Reconciliation & repair jobs","x":990,"y":744,"width":270,"height":56,"color":"4"},
{"id":"n32","type":"text","text":"## Wallet","x":1320,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n33","type":"text","text":"TON ownership proof","x":1320,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n34","type":"text","text":"Trezor safekeeping","x":1320,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n35","type":"text","text":"EVM wallet (Wagmi/Viem)","x":1320,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n36","type":"text","text":"Alchemy on-chain verify","x":1320,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n37","type":"text","text":"## Chat & Realtime","x":1650,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n38","type":"text","text":"1:1 Chat","x":1650,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n39","type":"text","text":"Socket.IO rooms","x":1650,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n40","type":"text","text":"Notifications","x":1650,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n41","type":"text","text":"Realtime authorization","x":1650,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n42","type":"text","text":"## Disputes","x":1980,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n43","type":"text","text":"Dispute flow","x":1980,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n44","type":"text","text":"Admin resolution","x":1980,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n45","type":"text","text":"Payout hold enforcement","x":1980,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n46","type":"text","text":"## Growth & Content","x":2310,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n47","type":"text","text":"Points","x":2310,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n48","type":"text","text":"Referrals","x":2310,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n49","type":"text","text":"Levels / LevelConfig","x":2310,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n50","type":"text","text":"Blog","x":2310,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n51","type":"text","text":"AI assistant (OpenAI)","x":2310,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n52","type":"text","text":"## Telegram","x":2640,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n53","type":"text","text":"Identity linking & session","x":2640,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n54","type":"text","text":"Bot commands & notifications","x":2640,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n55","type":"text","text":"Payment & wallet strategy","x":2640,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n56","type":"text","text":"Security & abuse controls","x":2640,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n57","type":"text","text":"Mini App shell","x":2640,"y":666,"width":270,"height":56,"color":"2"},
{"id":"n58","type":"text","text":"Escrow/dispute/release actions","x":2640,"y":744,"width":270,"height":56},
{"id":"n59","type":"text","text":"Admin & support surface","x":2640,"y":822,"width":270,"height":56},
{"id":"n60","type":"text","text":"## Admin & Ops","x":2970,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n61","type":"text","text":"Admin dashboard & API","x":2970,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n62","type":"text","text":"Monitoring (Sentry)","x":2970,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n63","type":"text","text":"CI/CD pipeline","x":2970,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n64","type":"text","text":"Backup & recovery","x":2970,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n65","type":"text","text":"Incident runbooks","x":2970,"y":666,"width":270,"height":56,"color":"4"},
{"id":"n66","type":"text","text":"Docker deployment","x":2970,"y":744,"width":270,"height":56,"color":"4"},
{"id":"n67","type":"text","text":"## Design System","x":3300,"y":220,"width":270,"height":64,"color":"5"},
{"id":"n68","type":"text","text":"MUI v7 components","x":3300,"y":354,"width":270,"height":56,"color":"4"},
{"id":"n69","type":"text","text":"Theming & dark mode","x":3300,"y":432,"width":270,"height":56,"color":"4"},
{"id":"n70","type":"text","text":"i18n — 6 locales + RTL","x":3300,"y":510,"width":270,"height":56,"color":"4"},
{"id":"n71","type":"text","text":"Typography & icons","x":3300,"y":588,"width":270,"height":56,"color":"4"},
{"id":"n72","type":"text","text":"### Legend\n\n🟩 **Done**\n\n🟧 **In progress**\n\n⬜ **Planned**","x":0,"y":1058,"width":300,"height":200}
],
"edges":[
{"id":"e1","fromNode":"n1","fromSide":"bottom","toNode":"n2","toSide":"top"},
{"id":"e2","fromNode":"n2","fromSide":"bottom","toNode":"n3","toSide":"top"},
{"id":"e3","fromNode":"n3","fromSide":"bottom","toNode":"n4","toSide":"top"},
{"id":"e4","fromNode":"n4","fromSide":"bottom","toNode":"n5","toSide":"top"},
{"id":"e5","fromNode":"n5","fromSide":"bottom","toNode":"n6","toSide":"top"},
{"id":"e6","fromNode":"n6","fromSide":"bottom","toNode":"n7","toSide":"top"},
{"id":"e7","fromNode":"n7","fromSide":"bottom","toNode":"n8","toSide":"top"},
{"id":"e8","fromNode":"n8","fromSide":"bottom","toNode":"n9","toSide":"top"},
{"id":"e9","fromNode":"n9","fromSide":"bottom","toNode":"n10","toSide":"top"},
{"id":"e10","fromNode":"n1","fromSide":"bottom","toNode":"n11","toSide":"top"},
{"id":"e11","fromNode":"n11","fromSide":"bottom","toNode":"n12","toSide":"top"},
{"id":"e12","fromNode":"n12","fromSide":"bottom","toNode":"n13","toSide":"top"},
{"id":"e13","fromNode":"n13","fromSide":"bottom","toNode":"n14","toSide":"top"},
{"id":"e14","fromNode":"n14","fromSide":"bottom","toNode":"n15","toSide":"top"},
{"id":"e15","fromNode":"n15","fromSide":"bottom","toNode":"n16","toSide":"top"},
{"id":"e16","fromNode":"n16","fromSide":"bottom","toNode":"n17","toSide":"top"},
{"id":"e17","fromNode":"n1","fromSide":"bottom","toNode":"n18","toSide":"top"},
{"id":"e18","fromNode":"n18","fromSide":"bottom","toNode":"n19","toSide":"top"},
{"id":"e19","fromNode":"n19","fromSide":"bottom","toNode":"n20","toSide":"top"},
{"id":"e20","fromNode":"n20","fromSide":"bottom","toNode":"n21","toSide":"top"},
{"id":"e21","fromNode":"n21","fromSide":"bottom","toNode":"n22","toSide":"top"},
{"id":"e22","fromNode":"n22","fromSide":"bottom","toNode":"n23","toSide":"top"},
{"id":"e23","fromNode":"n23","fromSide":"bottom","toNode":"n24","toSide":"top"},
{"id":"e24","fromNode":"n1","fromSide":"bottom","toNode":"n25","toSide":"top"},
{"id":"e25","fromNode":"n25","fromSide":"bottom","toNode":"n26","toSide":"top"},
{"id":"e26","fromNode":"n26","fromSide":"bottom","toNode":"n27","toSide":"top"},
{"id":"e27","fromNode":"n27","fromSide":"bottom","toNode":"n28","toSide":"top"},
{"id":"e28","fromNode":"n28","fromSide":"bottom","toNode":"n29","toSide":"top"},
{"id":"e29","fromNode":"n29","fromSide":"bottom","toNode":"n30","toSide":"top"},
{"id":"e30","fromNode":"n30","fromSide":"bottom","toNode":"n31","toSide":"top"},
{"id":"e31","fromNode":"n1","fromSide":"bottom","toNode":"n32","toSide":"top"},
{"id":"e32","fromNode":"n32","fromSide":"bottom","toNode":"n33","toSide":"top"},
{"id":"e33","fromNode":"n33","fromSide":"bottom","toNode":"n34","toSide":"top"},
{"id":"e34","fromNode":"n34","fromSide":"bottom","toNode":"n35","toSide":"top"},
{"id":"e35","fromNode":"n35","fromSide":"bottom","toNode":"n36","toSide":"top"},
{"id":"e36","fromNode":"n1","fromSide":"bottom","toNode":"n37","toSide":"top"},
{"id":"e37","fromNode":"n37","fromSide":"bottom","toNode":"n38","toSide":"top"},
{"id":"e38","fromNode":"n38","fromSide":"bottom","toNode":"n39","toSide":"top"},
{"id":"e39","fromNode":"n39","fromSide":"bottom","toNode":"n40","toSide":"top"},
{"id":"e40","fromNode":"n40","fromSide":"bottom","toNode":"n41","toSide":"top"},
{"id":"e41","fromNode":"n1","fromSide":"bottom","toNode":"n42","toSide":"top"},
{"id":"e42","fromNode":"n42","fromSide":"bottom","toNode":"n43","toSide":"top"},
{"id":"e43","fromNode":"n43","fromSide":"bottom","toNode":"n44","toSide":"top"},
{"id":"e44","fromNode":"n44","fromSide":"bottom","toNode":"n45","toSide":"top"},
{"id":"e45","fromNode":"n1","fromSide":"bottom","toNode":"n46","toSide":"top"},
{"id":"e46","fromNode":"n46","fromSide":"bottom","toNode":"n47","toSide":"top"},
{"id":"e47","fromNode":"n47","fromSide":"bottom","toNode":"n48","toSide":"top"},
{"id":"e48","fromNode":"n48","fromSide":"bottom","toNode":"n49","toSide":"top"},
{"id":"e49","fromNode":"n49","fromSide":"bottom","toNode":"n50","toSide":"top"},
{"id":"e50","fromNode":"n50","fromSide":"bottom","toNode":"n51","toSide":"top"},
{"id":"e51","fromNode":"n1","fromSide":"bottom","toNode":"n52","toSide":"top"},
{"id":"e52","fromNode":"n52","fromSide":"bottom","toNode":"n53","toSide":"top"},
{"id":"e53","fromNode":"n53","fromSide":"bottom","toNode":"n54","toSide":"top"},
{"id":"e54","fromNode":"n54","fromSide":"bottom","toNode":"n55","toSide":"top"},
{"id":"e55","fromNode":"n55","fromSide":"bottom","toNode":"n56","toSide":"top"},
{"id":"e56","fromNode":"n56","fromSide":"bottom","toNode":"n57","toSide":"top"},
{"id":"e57","fromNode":"n57","fromSide":"bottom","toNode":"n58","toSide":"top"},
{"id":"e58","fromNode":"n58","fromSide":"bottom","toNode":"n59","toSide":"top"},
{"id":"e59","fromNode":"n1","fromSide":"bottom","toNode":"n60","toSide":"top"},
{"id":"e60","fromNode":"n60","fromSide":"bottom","toNode":"n61","toSide":"top"},
{"id":"e61","fromNode":"n61","fromSide":"bottom","toNode":"n62","toSide":"top"},
{"id":"e62","fromNode":"n62","fromSide":"bottom","toNode":"n63","toSide":"top"},
{"id":"e63","fromNode":"n63","fromSide":"bottom","toNode":"n64","toSide":"top"},
{"id":"e64","fromNode":"n64","fromSide":"bottom","toNode":"n65","toSide":"top"},
{"id":"e65","fromNode":"n65","fromSide":"bottom","toNode":"n66","toSide":"top"},
{"id":"e66","fromNode":"n1","fromSide":"bottom","toNode":"n67","toSide":"top"},
{"id":"e67","fromNode":"n67","fromSide":"bottom","toNode":"n68","toSide":"top"},
{"id":"e68","fromNode":"n68","fromSide":"bottom","toNode":"n69","toSide":"top"},
{"id":"e69","fromNode":"n69","fromSide":"bottom","toNode":"n70","toSide":"top"},
{"id":"e70","fromNode":"n70","fromSide":"bottom","toNode":"n71","toSide":"top"}
]
}

View File

@@ -0,0 +1,39 @@
---
issue: 001
title: "PATCH /api/disputes/:id/status and POST /api/disputes/:id/resolve have no role guard — privilege escalation"
severity: critical
domain: Dispute
labels: [security, bug, backend, privilege-escalation]
status: resolved
resolved: 2026-05-29
fix: "Added authorizeRoles('admin') middleware to PATCH /:id/status and POST /:id/resolve in backend/src/routes/disputeRoutes.ts"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 PATCH /api/disputes/:id/status and POST /api/disputes/:id/resolve have no role guard — privilege escalation
**Severity:** critical
**Domain:** Dispute
**Labels:** security, bug, backend, privilege-escalation
## Description
Any authenticated buyer or seller can change dispute status to 'resolved', 'closed', or 'rejected', and can post a dispute resolution including action=ban_seller. Neither the dashboard updateStatus controller nor the resolveDispute controller call authorizeRoles('admin'). Only authenticateToken is applied on the router.
## Current Behavior
Any authenticated user with the dispute ID can call PATCH /api/disputes/:id/status or POST /api/disputes/:id/resolve and receive 200 with the mutation applied.
## Expected Behavior
Both endpoints should return 403 for non-admin users. authorizeRoles('admin') middleware should be applied at the route level.
## Affected Files
- `backend/src/routes/disputeRoutes.ts`
- `backend/src/controllers/disputeController.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

View File

@@ -0,0 +1,39 @@
---
issue: 002
title: "POST /api/disputes/:id/assign has no role guard — any user can self-assign as admin"
severity: critical
domain: Dispute
labels: [security, bug, backend, privilege-escalation]
status: resolved
resolved: 2026-05-29
fix: "Added authorizeRoles('admin') middleware to POST /:id/assign in backend/src/routes/disputeRoutes.ts"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 POST /api/disputes/:id/assign has no role guard — any user can self-assign as admin
**Severity:** critical
**Domain:** Dispute
**Labels:** security, bug, backend, privilege-escalation
## Description
The POST /api/disputes/:id/assign endpoint registers only authenticateToken. Any authenticated user can assign themselves or anyone else as the admin handler for a dispute. The admin check is absent at both the middleware and controller level.
## Current Behavior
Any authenticated buyer or seller can call POST /api/disputes/:id/assign and become the assigned admin for the dispute.
## Expected Behavior
Return 403 for non-admin tokens. Apply authorizeRoles('admin') at the route level.
## Affected Files
- `backend/src/routes/disputeRoutes.ts`
- `backend/src/controllers/disputeController.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

View File

@@ -0,0 +1,43 @@
---
issue: 003
title: "Route shadowing: POST /api/disputes/:purchaseRequestId/resolve matches dashboard router first and executes wrong handler"
severity: critical
domain: Dispute
labels: [bug, backend, critical, escrow]
status: resolved
resolved: 2026-05-29
fix: "Remounted services/dispute router at /api/disputes/pr instead of /api/disputes — eliminates route overlap with dashboard router"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 Route shadowing: POST /api/disputes/:purchaseRequestId/resolve matches dashboard router first and executes wrong handler
**Severity:** critical
**Domain:** Dispute
**Labels:** bug, backend, critical, escrow
## Description
Both the dashboard disputeRoutes and the releaseHold disputeRoutes are mounted at /api/disputes in app.ts. The dashboard router is mounted first (line 521). A POST /api/disputes/{purchaseRequestId}/resolve with a valid purchaseRequestId will match the dashboard router's POST /:id/resolve (Dispute CRUD resolve) before reaching the releaseHold router's escrow-unblocking resolve. The escrow hold is never cleared.
## Current Behavior
The dashboard router intercepts the request and executes Dispute model CRUD resolve only. Escrow hold is not cleared. Outcome is non-deterministic depending on whether the ID matches a Dispute _id.
## Expected Behavior
POST /api/disputes/:purchaseRequestId/resolve should reach the releaseHold handler and clear the escrow hold. Route registration order must be corrected or paths made unambiguous.
## Reproduction Steps
POST /api/disputes/{validPurchaseRequestId}/resolve with admin token — observe that escrow hold is NOT released, only the Dispute document is updated.
## Affected Files
- `backend/src/app.ts`
- `backend/src/routes/disputeRoutes.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

View File

@@ -0,0 +1,39 @@
---
issue: 004
title: "POST /api/disputes/:id/resolve (dashboard) does not trigger escrow release — only updates Dispute model"
severity: critical
domain: Dispute
labels: [bug, backend, escrow, major]
status: resolved
resolved: 2026-05-29
fix: "DisputeService.resolveDispute now calls releaseHoldResolve(purchaseRequestId) after saving, clearing escrow hold and unblocking payment release"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 POST /api/disputes/:id/resolve (dashboard) does not trigger escrow release — only updates Dispute model
**Severity:** critical
**Domain:** Dispute
**Labels:** bug, backend, escrow, major
## Description
The API claims resolveDispute 'triggers refund/release/split escrow action.' DisputeService.resolveDispute only updates the Dispute document. The separate POST /api/disputes/:purchaseRequestId/resolve (releaseHold router) is required to actually unblock escrow. Due to the route shadowing bug, the correct handler may never be reached.
## Current Behavior
DisputeService.resolveDispute only updates the Dispute document. Escrow remains blocked until a separate correct API call is made to the releaseHold router.
## Expected Behavior
Dispute resolution should atomically update the Dispute record AND release/refund the escrow as indicated by the action field.
## Affected Files
- `backend/src/services/disputeService.ts`
- `backend/src/controllers/disputeController.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

View File

@@ -0,0 +1,42 @@
---
issue: 005
title: "POST /api/payment/payments/:id/fetch-tx, POST /api/payment/payments/auto-fetch-missing, and GET /api/payment/payments/:id/debug have no authentication middleware"
severity: critical
domain: Payment
labels: [security, bug, backend, critical, missing-auth]
status: resolved
resolved: 2026-05-29
fix: "Added authenticateToken + authorizeRoles('admin') to /payments/:id/debug, /payments/:id/fetch-tx, and /payments/auto-fetch-missing in paymentRoutes.ts"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 POST /api/payment/payments/:id/fetch-tx, POST /api/payment/payments/auto-fetch-missing, and GET /api/payment/payments/:id/debug have no authentication middleware
**Severity:** critical
**Domain:** Payment
**Labels:** security, bug, backend, critical, missing-auth
## Description
Three payment utility/debug endpoints are mounted with zero authentication. Any unauthenticated caller can read full payment internals (including blockchain metadata and wallet monitor state) or trigger on-chain fetches and state writes. These are exploitable without credentials in production.
## Current Behavior
All three return 200 with full data when called without any Authorization header.
## Expected Behavior
All three endpoints should require at minimum authenticateToken, and ideally authorizeRoles('admin').
## Reproduction Steps
curl -X POST https://api.example.com/api/payment/payments/test123/fetch-tx — expect 401, currently returns 200.
## Affected Files
- `backend/src/routes/paymentRoutes.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

View File

@@ -0,0 +1,42 @@
---
issue: 006
title: "GET /api/admin/scanner/status has no authentication middleware despite /api/admin/ prefix"
severity: critical
domain: Admin
labels: [security, bug, backend, critical, missing-auth]
status: resolved
resolved: 2026-05-29
fix: "Added authenticateToken + authorizeRoles('admin') inline to the scanner status proxy route in app.ts"
created: 2026-05-29
source: Doc vs Code Audit 2026-05-29
---
# 🔴 GET /api/admin/scanner/status has no authentication middleware despite /api/admin/ prefix
**Severity:** critical
**Domain:** Admin
**Labels:** security, bug, backend, critical, missing-auth
## Description
The scanner status proxy endpoint at GET /api/admin/scanner/status proxies directly to AMN_SCANNER_URL without any authentication check, despite sitting under the /api/admin/ route prefix which conventionally requires admin auth.
## Current Behavior
Returns scanner data (200) to any unauthenticated request.
## Expected Behavior
Return 401 without Authorization header, 403 for non-admin token. Apply authenticateToken + authorizeRoles('admin').
## Reproduction Steps
curl https://api.example.com/api/admin/scanner/status — should return 401, currently returns scanner data.
## Affected Files
- `backend/src/routes/adminRoutes.ts`
## References
- [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)

Some files were not shown because too many files have changed in this diff Show More