Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ngn-rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 37 additions & 5 deletions docs/offramp-orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
250 changes: 245 additions & 5 deletions src/__tests__/routes/offramp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ const mockFindUnique = jest.fn(async ({ where }: { where: Record<string, any> })
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)
Expand Down Expand Up @@ -106,6 +108,7 @@ beforeEach(() => {
mockCreate.mockReset();
mockStatus.mockReset();
mockFindUnique.mockClear();
mockRowCreate.mockClear();
mockCreate.mockImplementation(async () => linqOrder());
mockStatus.mockImplementation(async (id: string) => ({
id,
Expand All @@ -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}` });
Expand Down Expand Up @@ -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<string, unknown> = {}) {
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/);
});
});
Loading
Loading