docs(ops): handover for Request Network intent duplicate key bug
Captures the E11000 collision on the uniq_pending_request_network_by_buyer_session index, identifies reused purchaseRequestId as the root cause, and lays out the mongo unblock, frontend id-rotation, and backend idempotency fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
18
.gitleaks.toml
Normal file
18
.gitleaks.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
title = "nick-doc gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# 'Pangolin/Newt' is the literal product name of a self-hosted tunneling tool
|
||||
# mentioned in operational handoff docs, not a secret. The generic-api-key
|
||||
# rule fires on entropy heuristics for the surrounding line.
|
||||
[[allowlists]]
|
||||
description = "Documentation-only false positives"
|
||||
regexes = [
|
||||
'''Pangolin/Newt''',
|
||||
]
|
||||
|
||||
# Pin the known historical finding so we don't mask future leaks in the file.
|
||||
[[allowlists]]
|
||||
description = "Pre-existing FP in Telegram Mini App handoff doc, 2026-05-24"
|
||||
commits = ["940ad0c655777e3bf6d5416fd2829be77bdfc4f8"]
|
||||
@@ -0,0 +1,128 @@
|
||||
# Handover — Request Network Intent: Duplicate Key Bug
|
||||
|
||||
**Date:** 2026-05-27
|
||||
**Endpoint:** `POST https://dev.amn.gg/api/payment/request-network/intents`
|
||||
**Severity:** Blocks checkout retry flow on Request Network payments
|
||||
**Status:** Reproducible in production (`dev.amn.gg`), root cause identified, three remediation paths proposed
|
||||
|
||||
---
|
||||
|
||||
## 1. Symptom
|
||||
|
||||
When a buyer attempts to create a Request Network payment intent from the checkout step 2 page, the backend returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "E11000 duplicate key error collection: marketplace.payments index: uniq_pending_request_network_by_buyer_session dup key: { buyerId: ObjectId('68e3a21fbc79e4364c20a07e'), purchaseRequestId: \"template-checkout-1779856632092\", provider: \"request.network\", direction: \"in\" }"
|
||||
}
|
||||
```
|
||||
|
||||
The raw MongoDB error is being surfaced to the client, which is a secondary issue (information leak + ugly UX).
|
||||
|
||||
## 2. Environment (verified correct)
|
||||
|
||||
The infrastructure side is **not the cause**. All env vars and dashboard config are aligned:
|
||||
|
||||
- `REQUEST_NETWORK_API_KEY` — matches the dashboard "amn" Client ID, status **Active**.
|
||||
- `REQUEST_NETWORK_MERCHANT_REFERENCE` — matches dashboard destination wallet (`0x05E2…573e`) on BNB Chain, USDC token slug.
|
||||
- `REQUEST_NETWORK_ORIGIN=https://dev.amn.gg` — present in the Client ID's Allowed Domains (along with `https://amn.gg`).
|
||||
- Webhook endpoint `https://dev.amn.gg/api/payment/request-network/webhook` is registered and **Active** in the dashboard.
|
||||
- `REQUEST_NETWORK_ENABLED=true`, `PAYMENT_PROVIDER_MODE=live`.
|
||||
|
||||
The Request Network API was never reached on this failure — the error happens **inside our backend** before any outbound call.
|
||||
|
||||
## 3. Root cause
|
||||
|
||||
The `payments` collection has a unique (partial) index:
|
||||
|
||||
```
|
||||
uniq_pending_request_network_by_buyer_session
|
||||
keys: { buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }
|
||||
```
|
||||
|
||||
The frontend submits a `purchaseRequestId` of the form `template-checkout-<timestamp>`. In the failing request:
|
||||
|
||||
```
|
||||
purchaseRequestId: "template-checkout-1779856632092"
|
||||
buyerId: 68e3a21fbc79e4364c20a07e
|
||||
provider: request.network
|
||||
direction: in
|
||||
```
|
||||
|
||||
A previous attempt — almost certainly a retry from the same page session — **already inserted a Payment document with this exact key tuple and left it in a pending state**. The unique index correctly rejects the second insert.
|
||||
|
||||
This is a logic bug in how the intent endpoint and the frontend handle retries, not a database bug. The index is doing exactly what it should: preventing duplicate pending intents for the same checkout session.
|
||||
|
||||
### Why it triggers in practice
|
||||
|
||||
- `purchaseRequestId` is generated client-side from a timestamp and **persisted in component/page state**, so it does **not** rotate on retry.
|
||||
- If the first POST creates the Payment doc but the client then errors (network blip, validation issue elsewhere, double-click), the second POST collides.
|
||||
- The backend treats the endpoint as create-only rather than idempotent, so it tries `insertOne` every time.
|
||||
|
||||
## 4. Reproduction
|
||||
|
||||
1. Sign in as `buyer@marketplace.com`.
|
||||
2. Open `https://dev.amn.gg/dashboard/shops/checkout/?step=2` for a template checkout.
|
||||
3. Submit the intent successfully (or simulate a half-complete request that creates the Payment doc).
|
||||
4. Submit again from the same page state without regenerating `purchaseRequestId`.
|
||||
5. Observe the E11000 response.
|
||||
|
||||
Exact payload that reproduces is captured in the original ticket (`amount: 12`, `token: "USDT"`, `network: "bsc"`, `sellerId: "6918535be9301e0e4358d83e"`).
|
||||
|
||||
## 5. Solutions
|
||||
|
||||
Three layered fixes. Apply (a) immediately to unblock; (b) and (c) are the durable fix.
|
||||
|
||||
### a) Hot unblock — clear the stale pending document
|
||||
|
||||
Run in the Mongo shell against the `marketplace` database:
|
||||
|
||||
```js
|
||||
db.payments.deleteOne({
|
||||
buyerId: ObjectId("68e3a21fbc79e4364c20a07e"),
|
||||
purchaseRequestId: "template-checkout-1779856632092",
|
||||
provider: "request.network",
|
||||
direction: "in",
|
||||
status: { $in: ["pending", "initiated", "awaiting_payment"] }
|
||||
});
|
||||
```
|
||||
|
||||
Then retry the checkout. Use this only for the specific buyer/session being unblocked — do **not** broad-delete pending Payments.
|
||||
|
||||
### b) Frontend — rotate `purchaseRequestId` on every retry
|
||||
|
||||
Locate the checkout step 2 component that builds the `template-checkout-<timestamp>` id. Today this value is computed once and reused across retries. Change it so:
|
||||
|
||||
- A fresh id is generated every time the user lands on (or re-enters) step 2.
|
||||
- A fresh id is generated when the user clicks "Pay" after a previous failure — i.e. tie generation to the click handler, not to mount, OR clear the cached value on any error response.
|
||||
- Prefer a UUID/ULID over a timestamp to make the intent collision-proof even across rapid clicks.
|
||||
|
||||
This eliminates the collision from the client side and is the minimum fix.
|
||||
|
||||
### c) Backend — make `/intents` idempotent
|
||||
|
||||
The endpoint is semantically an *intent*: the same (buyer, purchaseRequest, provider, direction) tuple should always resolve to the same Payment document. Change the controller for `POST /api/payment/request-network/intents` to:
|
||||
|
||||
1. Look up an existing Payment matching `{ buyerId, purchaseRequestId, provider: "request.network", direction: "in" }` in any non-terminal status (`pending`, `initiated`, `awaiting_payment`).
|
||||
2. If found, return that Payment (and its Request Network handoff data) with HTTP 200 — do **not** insert.
|
||||
3. If not found, create the new Payment as today.
|
||||
4. Wrap the insert in a try/catch on `E11000`; on collision, re-read and return the existing doc (handles the race between two concurrent requests).
|
||||
|
||||
This is the correct long-term shape and also defends against double-clicks, browser back/forward, and React strict-mode double-invocations.
|
||||
|
||||
Additionally:
|
||||
|
||||
- Stop returning raw Mongo error strings to the client. Map `E11000` on this collection to an HTTP 409 with a sanitized body like `{ success: false, code: "INTENT_ALREADY_EXISTS" }`.
|
||||
- Log the raw error server-side only.
|
||||
|
||||
## 6. Out of scope (but worth noting)
|
||||
|
||||
- The webhook **Signing Secret** in the dashboard shows `Unavailable` for the active webhook. `REQUEST_NETWORK_WEBHOOK_SECRET` is set in env, but verify the value matches what the dashboard issued at webhook creation — if not, regenerate and update env. This will bite the next time a payment actually clears.
|
||||
- The `amount: 12` is sent as `USDT` in the payload, but `REQUEST_NETWORK_PAYMENT_CURRENCY=USDC` and the merchant reference's token slug is the BSC USDC contract. Confirm whether the frontend should be sending `USDC` or whether the backend is supposed to normalize.
|
||||
|
||||
## 7. Suggested ownership
|
||||
|
||||
- **(a)** — Ops / whoever has Mongo access. One-shot.
|
||||
- **(b)** — Frontend dev owning `dashboard/shops/checkout`.
|
||||
- **(c)** — Backend dev owning `payment/request-network` controllers and the `Payment` model. This should land as a single PR with a regression test that fires two identical intent POSTs and asserts the second returns 200 with the same payment id.
|
||||
Reference in New Issue
Block a user