luccas@portfolio: less
luccas@portfolio:~$ less cases/regulated-money-path.md

Licensed real-money game platform · Cyrex Enterprise · only engineer, working with the client · 2026 · NestJS, PostgreSQL, React

Four providers on a regulated money path

- withdrawals stuck since May
+ first payout paid, 4 causes fixed
- 2 of 5 licence controls, ticket QA Ready
+ self-exclusion built

context

A licensed real-money backgammon platform, on the web and as a Telegram Mini App. I was the only engineer, working directly with the client.

Money moved through four third parties. ePay, the card payment gateway. Calypso, the crypto payment gateway. Shufti Pro for identity verification (KYC). CrossClassify for device and behavioral fraud detection.

There was no production traffic yet. Staging was the client's real environment. Calypso had no test environment, so payouts ran against its production.

problem

Each provider had a contract on paper and a behaviour in practice. They did not match.

Withdrawals had been stuck since May. The licence listed five mandatory player-protection controls. Two existed, and the ticket said "QA Ready".

decision

Chose: the provider never decides what we credit. A callback is a trigger, never proof. The card webhook is unsigned and the URL is public, so a body saying "paid" is a claim. We keep only the reference and re-read status and amount over an authenticated call we open ourselves. A cron reconciles whatever stays pending.

Rejected: crediting from the callback body.

Tested on purpose: the same pending payment, with the gateway reporting 25.00 against the 10 we registered. Reconciliation refused it.

Second rule: the front never knows the provider. The server derives crypto | card. Rejected: if (provider === ...) in the client.

mechanism

Regulated money path: player, our API, card and crypto providers, server-side re-read, ledger, withdrawal gates player web or Telegram Mini App our API the provider never sets the amount ePay (card) unsigned webhook Calypso (crypto) IP allowlist, 8 decimals re-read our call: status, amount cut as string refuse above 2^25 ledger credit once, cron reconciles Shufti Pro (KYC) gate on withdrawal licence limits deposit/session/exclusion
Every amount is ours. Providers only trigger a re-read.

card: a second deposit rail, from zero

Before it, a player without a crypto wallet could not deposit. ePay had no sandbox.

Two different errors from the gateway were the same error. The integration followed the docs. The gateway spoke another protocol. I stopped permuting base64 padding when I measured the key: 27 readable characters, against 64 hex in the docs. I went back to the provider with that evidence.

A five-form experiment showed the gateway collects the player's details itself. So we send none. End to end on the real gateway: USD 10, a test card, 100 coins credited.

crypto payout: stuck since May, four causes

  • Our IP was outside Calypso's allowlist. It had been treated as a credential problem for a month.
  • Amounts carried 17 decimals, from dividing twice.
  • Wallet addresses had no format check.
  • Negative points passed the balance check.

The first two only showed up after I logged the response body the code was discarding.

Amounts are cut as strings to the gateway's 8 decimals. Above 2^25 a double cannot guarantee 8 decimals, so the guard refuses instead of overpaying. Measured over 324 thousand samples. I ran the first payout since May myself, on 19 August.

KYC and fraud

KYC built from zero on Shufti Pro, with a signed webhook. It is the gate on withdrawals, which the licence's anti-money-laundering duty requires.

Fraud: the CrossClassify SDK on login and signup, a takeover hook on the backend. Before the scoping meeting I read CrossClassify's API contract. No "score". No ingestion endpoint. So the risk model the team had designed had to be built in-house. I also corrected my own "done" to the PM, before launch.

the licence controls

  • Deposit limits per rolling window, with a 24 h cooling-off. Lowering applies at once. Raising waits.
  • Several checkouts opened together breached the cap: checked at open, credited later by webhook. One open checkout per player now, by an atomic constraint.
  • Session limits rebuilt after reading the Responsible Gaming Policy. Redis was vetoed: the policy needs an audit trail.
  • Irrevocable self-exclusion. 848 tests, 26 of 26 mutants killed.
  • Where policy, design document and code disagreed, I escalated. Not fixed quietly in code.

security

An audit of the 25 handlers a player token reaches. 5 failed, two of them deletes with no ownership check. One guard now denies by default. I also closed an account-takeover path in an approved sign-in flow, by inverting who asserts identity and adding a PKCE-style proof of possession.

what changed

beforeafter
withdrawalsstuck since Mayfirst payout paid 19 Aug, four causes fixed
deposit railscrypto onlycrypto and card
payout amountsfloat mathstring cut, refuse above 2^25 (324k samples)
licence controls2 of 5, ticket QA Readyself-exclusion built
ownership checks5 of 25 handlers failingdefault-deny guard
cases/regulated-money-path.md (2/4) :q home · ls cases/ · ask the chat