Files
nick-doc/08 - Operations/Handover - Request Network Intent Duplicate Key Bug.md
Siavash Sameni fdb92a5056 docs: adopt RTK.md as canonical rule set; update RN handover
Moves the canonical agent rule set into nick-doc/RTK.md (previously only
present in the untracked escrow root). backend/AGENTS.md and
frontend/AGENTS.md now point here instead of duplicating the rules
3-ways and drifting.

New rules introduced as part of this session:
- Every build patch-bumps the version (image tracker on git.manko.yoga
  overwrites tags otherwise).
- Pre-deploy CLI verification: smoke tests in scripts/smoke/ must pass
  before pushing a build-triggering commit.
- CI notification safety: HTML-escape commit messages and strip git
  trailers; never embed {{commit.message}} directly in the telegram
  plugin's HTML-formatted body.

Handover doc updated to record that the Request Network checkout flow is
now end-to-end working at 2.6.20 (idempotency in bdbcc32, v2 wire shape
in 40750d3).

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
2026-05-27 09:56:43 +04:00

7.8 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. (c) is implemented as of 2026-05-27 in backend/src/services/payment/requestNetwork/{requestNetworkPayInService,requestNetworkRoutes}.ts (nick/backend@bdbcc32). Apply (a) once to clear the existing stuck doc. (b) is a frontend hygiene improvement worth keeping on the backlog but is no longer required to unblock checkouts.

Secondary fix shipped at the same time (nick/backend@40750d3, 2.6.20). Once the idempotency check passed, every call was failing with Request Network secure payment creation failed: HTTP 400 because the adapter was sending a flat payload to /v2/secure-payments, while the v2 endpoint requires { reference, requests:[{ destinationId, amount, metadata? }], redirectUrl?, callbackUrl? }. The translation now happens inside createSecurePaymentRequest (the rich internal payload object is still passed to the response mapper for paymentCurrency/network context). Verified end-to-end with backend/scripts/smoke/rn-payload-shape.mjs against the real RN API: HTTP 201 with securePaymentUrl + requestIds[].

a) Hot unblock — clear the stale pending document

Run in the Mongo shell against the marketplace database:

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.