Files
nick-doc/08 - Operations/Handover - Request Network Intent Duplicate Key Bug.md
Siavash Sameni a9d7bf003d 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>
2026-05-27 09:01:06 +04:00

6.9 KiB

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:

{
  "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:

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.