From 5ba5a8eb653a67fd138352f5c3ec1e3039f965f5 Mon Sep 17 00:00:00 2001 From: Glorious21 <184563826+Glorious21@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:24:12 +0100 Subject: [PATCH] Fix #187: validate offramp amounts, answer a lost insert race as a replay, bind the key to the request Rebased onto main after #216. The replay path, wallet check and amount check already live there, so this keeps only what was missing: zod amount validation with separate naira and stablecoin bounds and a safe fallback for a bad env value; a unique-violation fallback that goes through the same replay checks and returns our id and a fresh token; and a keyed fingerprint of the request stored with the order so a replay that changes the destination is refused. The zod version bump is dropped. --- docs/ngn-rails.md | 2 + docs/offramp-orders.md | 42 +++- prisma/schema.prisma | 6 + src/__tests__/routes/offramp.test.ts | 250 +++++++++++++++++++++++- src/api/offramp.ts | 276 +++++++++++++++++++++------ 5 files changed, 503 insertions(+), 73 deletions(-) diff --git a/docs/ngn-rails.md b/docs/ngn-rails.md index 938c573b..7d9b7b6e 100644 --- a/docs/ngn-rails.md +++ b/docs/ngn-rails.md @@ -354,6 +354,8 @@ instance can outlast that. | `SOROBAN_RPC_URL_MAINNET` | for indexing | **Not needed by these rails** — they are plain HTTP to Linq. But listing `mainnet` in `NETWORKS` turns on the mainnet *indexer*, which does need it. Without it the indexer skips mainnet and says so; the API, and everything here, keeps serving. | | `OFFRAMP_LOOKUP_MAX_FAILURES` | no (`10`) | Offramp failed lookups per window. | | `OFFRAMP_LOOKUP_WINDOW_MS` | no (`900000`) | Offramp lookup window. | +| `OFFRAMP_MAX_AMOUNT_NGN` | no (`10000000`) | Largest `amountNGN` an offramp order may carry. | +| `OFFRAMP_MAX_AMOUNT_STABLECOIN` | no (`10000`) | Largest `amountStableCoin` an offramp order may carry. | ## Schema convergence diff --git a/docs/offramp-orders.md b/docs/offramp-orders.md index e012a54b..598e8418 100644 --- a/docs/offramp-orders.md +++ b/docs/offramp-orders.md @@ -14,6 +14,18 @@ reading one back requires proof of having created it. The response is sent with `Cache-Control: no-store`. +Send either `amountNGN` or `amountStableCoin`, as a JSON number or a plain decimal +string. Anything else (`"abc"`, a negative or zero amount, `"Infinity"`, a boolean, +an array) is refused with `400` before any lookup or provider call. Each currency +has its own ceiling: + +| Variable | Default | Purpose | +| --------------------------------- | ------------ | ------- | +| `OFFRAMP_MAX_AMOUNT_NGN` | `10000000` | Largest `amountNGN` accepted. | +| `OFFRAMP_MAX_AMOUNT_STABLECOIN` | `10000` | Largest `amountStableCoin` accepted. | + +A value that is not a positive number is ignored and the default applies. + ## Reading an order ```bash @@ -38,11 +50,28 @@ consumes it. Defaults: 10 failures per 15 minutes per IP. ## Retrying a creation -Replaying `POST /offramp/orders` with the same `idempotencyKey` and -`walletAddress` returns the same order (`replayed: true`) with the same `id` and -a **new** `accessToken`; the previous token stops working. A different -`walletAddress` gets `409` and nothing about the order. Idempotency keys should -therefore be random and generated by the client. +Replaying `POST /offramp/orders` with the same `idempotencyKey` and an identical +request returns the same order (`replayed: true`) with the same `id` and a **new** +`accessToken`; the previous token stops working. A replay only counts as identical +when the wallet, the amount, `bankAccount`, `bankCode`, `accountName` and +`refundAddress` all match: + +| Replay differs in | Result | +| ------------------------------------- | ------ | +| `walletAddress` | `409`, and nothing about the order. | +| the amount | `409` (different request). | +| bank account, bank code, account name or refund address | `409` (different order details). | + +Idempotency keys should therefore be random and generated by the client. + +Two simultaneous requests with the same key resolve the same way: one creates the +order, the other is answered as the replay (`200`, `replayed: true`) rather than +with an error, after the same wallet and request checks. Each replay issues a new +token, so the token from the most recent response is the one that works. + +The request is compared through a stored HMAC-SHA256 fingerprint keyed with +`LINQ_API_KEY`, not by storing the bank details. Rotating that key means a retry +of an order created before the rotation is refused with `409`. ## Upgrading @@ -52,3 +81,6 @@ therefore be random and generated by the client. - The lookup response now reports `source: "live"` instead of `"linq"`. - Two nullable columns were added to `OfframpOrder` (`publicId`, `accessTokenHash`); run `npx prisma db push`. +- A third nullable column, `requestHash`, binds an idempotency key to the request + that created the order (`npx prisma db push` adds it; the server also does so at + boot). Orders created before it have none and are compared on amount alone. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4d9130e3..137df1d6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -398,6 +398,12 @@ model OfframpOrder { idempotencyKey String // Which wallet placed it, so a user can be shown their own orders. walletAddress String + // Keyed hash (HMAC-SHA256) of what the idempotencyKey was used to request: the + // amounts, bank account, bank code, account name, refund address and wallet. A + // replay is only a replay when it hashes the same. Keyed rather than a bare + // hash because an account number has too few digits to survive one. Null on + // rows written before this existed; those are checked on amount alone. + requestHash String? chain String @default("stellar") coin String @default("usdc") diff --git a/src/__tests__/routes/offramp.test.ts b/src/__tests__/routes/offramp.test.ts index 5ddbe85c..4e8b4230 100644 --- a/src/__tests__/routes/offramp.test.ts +++ b/src/__tests__/routes/offramp.test.ts @@ -18,15 +18,17 @@ const mockFindUnique = jest.fn(async ({ where }: { where: Record }) return rows.find((r) => fields.every((f) => r[f] === value[f])) ?? null; }); +const mockRowCreate = jest.fn(async ({ data }: { data: Row }) => { + const row = { id: nextId++, createdAt: new Date("2026-01-01T00:00:00Z"), ...data }; + rows.push(row); + return row; +}); + jest.mock("../../db", () => ({ prisma: { offrampOrder: { findUnique: (args: any) => mockFindUnique(args), - create: jest.fn(async ({ data }: { data: Row }) => { - const row = { id: nextId++, createdAt: new Date("2026-01-01T00:00:00Z"), ...data }; - rows.push(row); - return row; - }), + create: (args: any) => mockRowCreate(args), update: jest.fn(async ({ where, data }: { where: Row; data: Row }) => { const row = where.id ? rows.find((r) => r.id === where.id) @@ -106,6 +108,7 @@ beforeEach(() => { mockCreate.mockReset(); mockStatus.mockReset(); mockFindUnique.mockClear(); + mockRowCreate.mockClear(); mockCreate.mockImplementation(async () => linqOrder()); mockStatus.mockImplementation(async (id: string) => ({ id, @@ -117,6 +120,8 @@ beforeEach(() => { updated: "2026-01-01T00:00:00Z", })); delete process.env.OFFRAMP_LOOKUP_MAX_FAILURES; + delete process.env.OFFRAMP_MAX_AMOUNT_NGN; + delete process.env.OFFRAMP_MAX_AMOUNT_STABLECOIN; }); const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); @@ -463,3 +468,238 @@ describe("retrying an order creation", () => { expect(JSON.stringify(res.body)).not.toContain(LINQ_ID); }); }); + +describe("validating the amount", () => { + // Every one of these used to reach the provider: Number("abc") is NaN, and + // Number() also accepts negatives, zero, "" and booleans. + it.each([ + ["a non-numeric string", "abc"], + ["a negative number", -5], + ["zero", 0], + ["an empty string", ""], + ["Infinity as a string", "Infinity"], + ["an exponent that overflows", "1e999"], + ["a boolean", true], + ["an array", [5]], + ["an object", { value: 5 }], + ])("answers 400 before any provider call for %s", async (_label, bad) => { + const res = await place(makeApp(), { amountStableCoin: bad }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/amountStableCoin/); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockFindUnique).not.toHaveBeenCalled(); + expect(rows).toHaveLength(0); + }); + + it("answers 400 for a non-numeric amountNGN, and names that field", async () => { + const res = await place(makeApp(), { amountStableCoin: undefined, amountNGN: "abc" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/amountNGN/); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("requires an amount of some kind", async () => { + const res = await place(makeApp(), { amountStableCoin: undefined }); + + expect(res.status).toBe(400); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("accepts an amount sent as a decimal string and passes the number on", async () => { + const res = await place(makeApp(), { amountStableCoin: "50.50" }); + + expect(res.status).toBe(201); + expect(mockCreate.mock.calls[0][0].amountStableCoin).toBe(50.5); + }); + + it("bounds naira and stablecoin separately", async () => { + // 200,000 is a small naira payout and an enormous USDC one. + const naira = await place(makeApp(), { + amountStableCoin: undefined, + amountNGN: 200_000, + idempotencyKey: "key-naira", + }); + const usdc = await place(makeApp(), { + amountStableCoin: 200_000, + idempotencyKey: "key-usdc", + }); + + expect(naira.status).toBe(201); + expect(usdc.status).toBe(400); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it("refuses an amount above the configured ceiling", async () => { + process.env.OFFRAMP_MAX_AMOUNT_STABLECOIN = "100"; + + const over = await place(makeApp(), { amountStableCoin: 101 }); + const at = await place(makeApp(), { amountStableCoin: 100, idempotencyKey: "key-at" }); + + expect(over.status).toBe(400); + expect(at.status).toBe(201); + }); + + it("falls back to the default bound when the configured one is not a number", async () => { + // A typo in config used to become a bound of NaN, which rejects everything. + process.env.OFFRAMP_MAX_AMOUNT_STABLECOIN = "ten thousand"; + process.env.OFFRAMP_MAX_AMOUNT_NGN = "-1"; + + const usdc = await place(makeApp(), { amountStableCoin: 50 }); + const tooMuch = await place(makeApp(), { + amountStableCoin: 10_001, + idempotencyKey: "key-too-much", + }); + const naira = await place(makeApp(), { + amountStableCoin: undefined, + amountNGN: 10_000_001, + idempotencyKey: "key-naira", + }); + + expect(usdc.status).toBe(201); + expect(tooMuch.status).toBe(400); + expect(naira.status).toBe(400); + }); +}); + +describe("binding an idempotency key to the request", () => { + it("stores a keyed fingerprint that does not contain the bank details", async () => { + await place(makeApp()); + + expect(rows[0].requestHash).toMatch(/^[0-9a-f]{64}$/); + expect(rows[0].requestHash).not.toContain("0123456789"); + }); + + it.each([ + ["bankAccount", { bankAccount: "9999999999" }], + ["bankCode", { bankCode: "044" }], + ["accountName", { accountName: "Someone Else" }], + [ + "refundAddress", + { refundAddress: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBKOH" }, + ], + ])("refuses a replay that changes only the %s", async (_field, change) => { + const app = makeApp(); + const first = await place(app); + expect(first.status).toBe(201); + const tokenHash = rows[0].accessTokenHash; + + const replay = await place(app, change); + + expect(replay.status).toBe(409); + expect(replay.body.error).toMatch(/different order details/i); + expect(replay.body.accessToken).toBeUndefined(); + expect(replay.body.replayed).toBeUndefined(); + // Nothing new reached the provider, and the first order's token is untouched. + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(rows).toHaveLength(1); + expect(rows[0].accessTokenHash).toBe(tokenHash); + }); + + it("still treats an identical retry as a replay", async () => { + const app = makeApp(); + await place(app); + const retry = await place(app); + + expect(retry.status).toBe(200); + expect(retry.body.replayed).toBe(true); + }); + + it("keeps the same fingerprint for the same request, and a different one for another", async () => { + await place(makeApp(), { idempotencyKey: "key-a" }); + await place(makeApp(), { idempotencyKey: "key-b" }); + await place(makeApp(), { idempotencyKey: "key-c", bankAccount: "9999999999" }); + + expect(rows[0].requestHash).toBe(rows[1].requestHash); + expect(rows[2].requestHash).not.toBe(rows[0].requestHash); + }); +}); + +describe("concurrent duplicates", () => { + const uniqueViolation = () => + Object.assign( + new Error("Unique constraint failed on the fields: (`network`,`idempotencyKey`)"), + { code: "P2002" }, + ); + + // The second of two simultaneous taps: its lookup ran before the first tap's row + // was written (so it finds nothing), it created the order at the provider, and + // its insert then loses to the row that now exists. + async function placeLosingTheRace(app: express.Express, over: Record = {}) { + mockFindUnique.mockImplementationOnce(async () => null); + mockRowCreate.mockImplementationOnce(async () => { + throw uniqueViolation(); + }); + return place(app, over); + } + + it("answers 200 replayed, not 500, and returns our id and a fresh token", async () => { + const app = makeApp(); + const first = await place(app); + expect(first.status).toBe(201); + + const loser = await placeLosingTheRace(app); + + expect(loser.status).toBe(200); + expect(loser.body.replayed).toBe(true); + expect(loser.body.id).toBe(first.body.id); + expect(loser.body.id).toMatch(/^ofr_[A-Za-z0-9_-]{22}$/); + expect(loser.body.accessToken).toMatch(/^oft_[A-Za-z0-9_-]{43}$/); + expect(loser.body.accessToken).not.toBe(first.body.accessToken); + expect(loser.headers["cache-control"]).toBe("no-store"); + expect(rows).toHaveLength(1); + + // The id is one the lookup route accepts, and the token it returns opens it. + const read = await request(makeApp()) + .get(`/offramp/orders/${loser.body.id}`) + .set(auth(loser.body.accessToken)); + expect(read.status).toBe(200); + }); + + it("never returns the provider's order id", async () => { + const app = makeApp(); + await place(app); + + const loser = await placeLosingTheRace(app); + + expect(JSON.stringify(loser.body)).not.toContain(LINQ_ID); + }); + + it("does not let the race answer for a different wallet", async () => { + const app = makeApp(); + await place(app); + + const loser = await placeLosingTheRace(app, { + walletAddress: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBKOH", + }); + + expect(loser.status).toBe(409); + expect(loser.body.accessToken).toBeUndefined(); + expect(loser.body.id).toBeUndefined(); + expect(loser.text).not.toContain("GDEPOSITADDRESS"); + }); + + it("does not let the race answer for different order details", async () => { + const app = makeApp(); + await place(app); + + const loser = await placeLosingTheRace(app, { bankAccount: "9999999999" }); + + expect(loser.status).toBe(409); + expect(loser.body.accessToken).toBeUndefined(); + }); + + it("does not leak the database error if the winning row cannot be read back", async () => { + const app = makeApp(); + mockRowCreate.mockImplementationOnce(async () => { + throw uniqueViolation(); + }); + // Nothing is there to adopt: the insert failed on a constraint we cannot explain. + const res = await place(app); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: "Offramp request failed" }); + expect(res.text).not.toMatch(/Unique constraint|idempotencyKey|P2002/); + }); +}); diff --git a/src/api/offramp.ts b/src/api/offramp.ts index b233079a..c2fce080 100644 --- a/src/api/offramp.ts +++ b/src/api/offramp.ts @@ -1,5 +1,8 @@ +import { createHmac } from "crypto"; import { Router, Request, Response, NextFunction } from "express"; import rateLimit, { type RateLimitRequestHandler } from "express-rate-limit"; +import type { OfframpOrder } from "@prisma/client"; +import { z } from "zod"; import { prisma } from "../db"; import { requestNetwork } from "../middleware/network"; @@ -73,6 +76,151 @@ function sendLinqError(res: Response, err: unknown): void { res.status(500).json({ error: "Offramp request failed" }); } +/** + * The most one order may move, per currency. A naira ceiling is meaningless as a + * stablecoin one, so each has its own; both can be set in the environment. A value + * that is not a positive finite number is ignored rather than trusted: a typo in + * config must not turn into a bound of NaN that rejects every amount. + */ +const DEFAULT_MAX_AMOUNT_NGN = 10_000_000; +const DEFAULT_MAX_AMOUNT_STABLECOIN = 10_000; + +function amountCeiling(envName: string, fallback: number): number { + const parsed = Number(process.env[envName]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * An amount arrives as a JSON number or a decimal string. Anything else is + * refused rather than coerced: `Number("abc")` is NaN, `Number(true)` is 1 and + * `Number([5])` is 5, none of which is an amount somebody meant to send. + */ +function amountSchema(max: number) { + return z + .union([z.number(), z.string().trim().regex(/^\d+(\.\d+)?$/)]) + .transform(Number) + .pipe(z.number().positive().max(max)); +} + +/** + * Keyed hash of what an idempotency key was used to ask for. + * + * The key alone only says "this is the same attempt"; this says "and it asked for + * the same thing". Amounts are normalised numerically so "50" and "50.00" match. + * HMAC with the provider key, which is never stored beside the row, because a + * plain hash of a ten-digit account number can be reversed by trying them all. + */ +function requestFingerprint(r: { + amountNGN?: unknown; + amountStableCoin?: unknown; + bankAccount: string; + bankCode: string; + accountName: string; + refundAddress: unknown; + walletAddress: string; +}): string { + const amount = (v: unknown) => (v == null ? "" : String(Number(v))); + return createHmac("sha256", process.env.LINQ_API_KEY ?? "") + .update( + JSON.stringify([ + amount(r.amountNGN), + amount(r.amountStableCoin), + r.bankAccount, + r.bankCode, + r.accountName, + typeof r.refundAddress === "string" ? r.refundAddress : "", + r.walletAddress, + ]), + ) + .digest("hex"); +} + +/** True for Prisma's unique-constraint violation, without depending on its class. */ +function isUniqueViolation(err: unknown): boolean { + return (err as { code?: unknown } | null)?.code === "P2002"; +} + +/** + * Answers a request whose idempotency key already has an order. + * + * Shared by the up-front lookup and by the insert that loses a race, so a + * concurrent duplicate gets exactly the checks a sequential one does: it is not + * a way round the wallet or request comparison, and it hands back our id and a + * fresh token, never the provider's id. + */ +async function replayOrder( + res: Response, + existing: OfframpOrder, + request: { + walletAddress: string; + amountNGN?: unknown; + amountStableCoin?: unknown; + requestHash: string; + }, +): Promise { + // The key alone is not proof of ownership; the wallet that placed the order + // has to match too, and nothing about the order is said otherwise. + if (existing.walletAddress !== request.walletAddress) { + res.status(409).json({ error: "This idempotencyKey has already been used" }); + return; + } + // An idempotency key promises "this key means this request". Replaying it with + // different figures used to return 200 carrying the FIRST order's amount, rate + // and deposit address, so a caller that reused a key with a changed amount was + // told its new payout had been accepted while the money followed the original + // order. On the only money-moving router here, a silent success for a request + // nobody made is the worst available outcome. + // + // Compared numerically because both sides are decimal strings: "2000" and + // "2000.00" are the same request and must not 409. + const sameAmount = (stored: string, supplied: unknown): boolean => + supplied == null ? false : Number(stored) === Number(supplied); + const amountMatches = + request.amountNGN != null + ? sameAmount(existing.amountNGN, request.amountNGN) + : request.amountStableCoin != null + ? sameAmount(existing.amountStableCoin, request.amountStableCoin) + : false; + if (!amountMatches) { + res.status(409).json({ + error: + "This idempotencyKey was used for a different request. Use a new key for a new order.", + }); + return; + } + // The amounts above are all the row stores of its own accord, so a replay that + // changed only the destination used to pass. The stored fingerprint covers the + // rest. A row without one predates it and can only be held to the amount. + if (existing.requestHash != null && existing.requestHash !== request.requestHash) { + res.status(409).json({ + error: "This idempotencyKey was used with different order details", + }); + return; + } + // A retry usually means the first response never arrived, and with it the + // access token. Only its hash is stored, so a new one is issued and the old + // one stops working. + const creds = issueOrderCredentials(existing.publicId ?? undefined); + await prisma.offrampOrder.update({ + where: { id: existing.id }, + data: { publicId: creds.orderId, accessTokenHash: creds.tokenHash }, + }); + res.setHeader("Cache-Control", "no-store"); + res.status(200).json({ + id: creds.orderId, + accessToken: creds.token, + walletAddress: existing.depositAddress, + chain: existing.chain, + coin: existing.coin, + amountStableCoin: Number(existing.amountStableCoin), + amountNGN: Number(existing.amountNGN), + rate: Number(existing.rate), + status: statusForClients(existing.status), + providerStatus: existing.status, + replayed: true, + }); +} + /** * Limits how many lookups a client may get wrong. * @@ -201,6 +349,50 @@ export function createOfframpRouter( return; } + // Checked before the idempotency lookup and before anything reaches the + // provider: `Number("abc")` is NaN and `Number` also accepts negatives and + // Infinity, and none of those should be able to start a payout. + if (amountNGN == null && amountStableCoin == null) { + res.status(400).json({ error: "amountNGN or amountStableCoin is required" }); + return; + } + const parsedAmounts: { amountNGN?: number; amountStableCoin?: number } = {}; + if (amountNGN != null) { + const max = amountCeiling("OFFRAMP_MAX_AMOUNT_NGN", DEFAULT_MAX_AMOUNT_NGN); + const parsed = amountSchema(max).safeParse(amountNGN); + if (!parsed.success) { + res + .status(400) + .json({ error: `amountNGN must be a positive number no greater than ${max}` }); + return; + } + parsedAmounts.amountNGN = parsed.data; + } + if (amountStableCoin != null) { + const max = amountCeiling( + "OFFRAMP_MAX_AMOUNT_STABLECOIN", + DEFAULT_MAX_AMOUNT_STABLECOIN, + ); + const parsed = amountSchema(max).safeParse(amountStableCoin); + if (!parsed.success) { + res.status(400).json({ + error: `amountStableCoin must be a positive number no greater than ${max}`, + }); + return; + } + parsedAmounts.amountStableCoin = parsed.data; + } + + const requestHash = requestFingerprint({ + ...parsedAmounts, + bankAccount, + bankCode, + accountName, + refundAddress, + walletAddress, + }); + const replayOf = { walletAddress, ...parsedAmounts, requestHash }; + try { // The key comes from the caller and is never generated here: regenerating // it on a retry is precisely how one order becomes two, and the second one @@ -209,72 +401,12 @@ export function createOfframpRouter( where: { network_idempotencyKey: { network: net, idempotencyKey } }, }); if (existing) { - // The key alone is not proof of ownership; the wallet that placed the - // order has to match too, and nothing about the order is said otherwise. - if (existing.walletAddress !== walletAddress) { - res.status(409).json({ error: "This idempotencyKey has already been used" }); - return; - } - // An idempotency key promises "this key means this request". Replaying it - // with different figures used to return 200 carrying the FIRST order's - // amount, rate and deposit address, so a caller that reused a key with a - // changed amount was told its new payout had been accepted while the money - // followed the original order. On the only money-moving router here, a - // silent success for a request nobody made is the worst available outcome. - // - // Compared numerically because both sides are decimal strings: "2000" and - // "2000.00" are the same request and must not 409. - // - // PARTIAL, deliberately: the row stores the amounts but not bankAccount, - // bankCode, bankName or accountName, so a replay that changes only the - // destination still passes here. Closing that needs a stored hash of the - // idempotency-relevant fields, which needs a column and a migration — - // see the note in docs/ rather than guessing at one on a shared database. - const sameAmount = (stored: string, supplied: unknown): boolean => - supplied == null ? false : Number(stored) === Number(supplied); - const amountMatches = - amountNGN != null - ? sameAmount(existing.amountNGN, amountNGN) - : amountStableCoin != null - ? sameAmount(existing.amountStableCoin, amountStableCoin) - : false; - if (!amountMatches) { - res.status(409).json({ - error: - "This idempotencyKey was used for a different request. Use a new key for a new order.", - }); - return; - } - // A retry usually means the first response never arrived, and with it the - // access token. Only its hash is stored, so a new one is issued and the - // old one stops working. - const creds = issueOrderCredentials(existing.publicId ?? undefined); - await prisma.offrampOrder.update({ - where: { id: existing.id }, - data: { publicId: creds.orderId, accessTokenHash: creds.tokenHash }, - }); - res.setHeader("Cache-Control", "no-store"); - res.status(200).json({ - id: creds.orderId, - accessToken: creds.token, - walletAddress: existing.depositAddress, - chain: existing.chain, - coin: existing.coin, - amountStableCoin: Number(existing.amountStableCoin), - amountNGN: Number(existing.amountNGN), - rate: Number(existing.rate), - status: statusForClients(existing.status), - providerStatus: existing.status, - replayed: true, - }); + await replayOrder(res, existing, replayOf); return; } const order = await createOfframpOrder({ - ...(amountNGN != null ? { amountNGN: Number(amountNGN) } : {}), - ...(amountStableCoin != null - ? { amountStableCoin: Number(amountStableCoin) } - : {}), + ...parsedAmounts, bankAccount, bankCode, bankName, @@ -299,6 +431,7 @@ export function createOfframpRouter( accessTokenHash: creds.tokenHash, idempotencyKey, walletAddress, + requestHash, chain: order.chain ?? "stellar", coin: order.coin ?? "usdc", depositAddress: order.walletAddress, @@ -312,7 +445,24 @@ export function createOfframpRouter( res.setHeader("Cache-Control", "no-store"); res.status(201).json({ ...order, id: creds.orderId, accessToken: creds.token }); } catch (err) { - sendLinqError(res, err); + // Two taps of the same request both miss the lookup above; the provider + // dedupes them to one order, and the second insert then trips + // @@unique([network, idempotencyKey]). The order exists and is fine, so the + // loser is answered as the replay it is instead of with a 500. + if (isUniqueViolation(err)) { + try { + const winner = await prisma.offrampOrder.findUnique({ + where: { network_idempotencyKey: { network: net, idempotencyKey } }, + }); + if (winner) { + await replayOrder(res, winner, replayOf); + return; + } + } catch { + // Nothing of the database failure is passed on; fall through. + } + } + if (!res.headersSent) sendLinqError(res, err); } });