Subaddress isolation
per invoice.
The Monero RPC daemon (monero-wallet-rpc) binds exclusively to the internal loopback interface (127.0.0.1:18084) and is never exposed to the public internet. The gateway communicates with it via authenticated cURL over localhost only.
Instead of reusing wallet addresses, the engine requests a fresh cryptographic subaddress from create_address for every single invoice. Each subaddress is mathematically derived from the master wallet but is entirely unlinkable to any other address in the set — a blockchain analyst cannot determine whether two payments went to the same wallet.
This also enables precise, per-invoice payment detection. The watcher sweeps get_transfers and matches incoming transfers against the stored invoice address directly — no subaddress index reliance, immune to schema drift.
// RPC payload — request a fresh subaddress $payload = json_encode([ 'jsonrpc' => '2.0', 'id' => '0', 'method' => 'create_address', 'params' => ['account_index' => 0], ]); $ch = curl_init('http://127.0.0.1:18084/json_rpc'); curl_setopt_array($ch, [ CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, // Loopback only — never leaves the server ]); $res = json_decode(curl_exec($ch), true); $address = $res['result']['address']; // Persist address — matched by watcher, not index $stmt = $db->prepare('INSERT INTO invoices (address, crypto_amount, status, expires_at) VALUES (?, ?, "pending", ?)'); $stmt->execute([$address, $xmrAmount, $expires]);
Watch-only architecture.
Keys never touch the server.
// Build args as an array — zero shell involvement // proc_open() bypasses OS shell entirely, // making shell injection structurally impossible $args = [ BTC_ELECTRUM_CMD, '--offline', '--dir', BTC_WALLET_DIR, '-w', BTC_WALLET_FILE, 'createnewaddress', ]; $proc = proc_open($args, [ 1 => ['pipe', 'w'], // stdout 2 => ['pipe', 'w'], // stderr ], $pipes); $addr = trim(stream_get_contents($pipes[1])); proc_close($proc); // Validate before storing — reject any garbage if (!preg_match('/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,87}$/', $addr)) { throw new RuntimeException('Invalid BTC address'); }
Traditional gateways require a hot wallet on the server — private keys stored in plaintext or encrypted on disk. If the VPS is compromised, all funds are immediately accessible to the attacker.
The Noctyra BTC engine uses a strict watch-only Electrum wallet seeded from your master public key (zpub / xpub) only. The wallet can derive addresses and detect incoming payments but is cryptographically incapable of signing or broadcasting transactions. There are no spendable keys on the server.
Address generation uses BIP32/BIP44 hierarchical derivation, producing a fresh address for each invoice without any network request. The Electrum daemon runs in --offline mode for address generation, connecting to the network only when checking balances.
proc_open(array) instead of shell_exec(string) means user-supplied data can never be interpreted as shell commands — structurally, not by sanitisation.
| Approach | Private Keys on Server | Theft Risk if VPS Compromised | Noctyra Uses |
|---|---|---|---|
| Hot Wallet | Yes | Total loss | — |
| Encrypted Hot Wallet | Yes (encrypted) | High — keys in memory | — |
| Watch-Only (xpub) | Never | Zero — cannot spend | ✓ This |
The float-drift exploit.
Eliminated at the type level.
Most payment gateway implementations store crypto amounts as floating-point decimals — standard DOUBLE or PHP float. Due to IEEE 754 binary representation, these types cannot precisely represent many decimal fractions.
An attacker exploits this by intentionally sending an amount fractionally below the invoice total. A naive implementation using float comparison rounds up to "close enough" and marks the invoice as paid. The attacker receives their goods or credits having paid less than the required amount.
The Noctyra solution eliminates floats from all payment verification paths entirely. Every amount — at invoice creation, at RPC response, and at watcher sweep — is immediately converted to atomic integer units: Piconeros (10¹²) for XMR and Satoshis (10⁸) for BTC. Integer comparison is exact. If the attacker is short by even one single atomic unit, the strict >= check fails and the invoice remains unpaid.
// XMR: all amounts in Piconeros (1e12 per XMR) // BTC: all amounts in Satoshis (1e8 per BTC) // Floats are converted once, immediately, never used again function to_atomic(string|int|float $v, int $multiplier): int { // Handle scientific notation from RPC responses (e.g. 1.49089e+12) if (is_string($v) && stripos($v, 'e') !== false) { $v = sprintf('%.12F', (float)$v); } return (int) round((float) $v * $multiplier); } // Invoice amount stored in atomic units at creation $required = (int) $invoice['crypto_amount']; // already atomic // Amount confirmed on-chain from RPC — also atomic $confirmed = to_atomic($transfer['amount'], XMR_ATOMIC); // Integer comparison — exact, no rounding if ($confirmed >= $required && $confs >= XMR_CONFIRMATIONS_REQUIRED) { mark_paid($invoice['id']); }
Double-webhook prevention.
Guaranteed at the database level.
// SQLite WAL mode — enables concurrent readers // while serialising all write transactions $db->exec('PRAGMA journal_mode=WAL'); $db->beginTransaction(); // The WHERE clause is the entire lock. // If status is already "paid", rowCount() = 0. // No second process can ever fire the webhook. $upd = $db->prepare(' UPDATE invoices SET status = "paid", paid_at = ? WHERE id = ? AND status = "pending" '); $upd->execute([time(), $invoiceId]); $wasFirst = ($upd->rowCount() === 1); $db->commit(); // Webhook fires once and exactly once, // regardless of concurrent processes if ($wasFirst) { trigger_noctyra_webhook($invoiceData); }
A common failure mode in payment gateways is the double-webhook race condition. It occurs when a browser polling for payment status and the background watcher cron both process the same successful payment simultaneously — both see status as "pending", both fire the webhook, and the merchant system credits the order twice.
The Noctyra approach eliminates this by making the state transition atomic at the database level. The UPDATE ... WHERE status = "pending" pattern means only one concurrent writer can ever change the row — the database engine serialises all write transactions.
The second process that arrives — microseconds or milliseconds later — will execute the same UPDATE and receive rowCount() === 0 because the row is already "paid". The webhook never fires a second time. This guarantee holds regardless of the number of concurrent processes.
The gateway is
functionally invisible.
Without protection, a public checkout endpoint leaks business intelligence. An attacker iterating ?id=1, ?id=2, ... can reconstruct your entire transaction history, revenue volume, and customer cadence. Alternatively they can spam invoice creation to exhaust your database or overwhelm the wallet RPC daemon.
Every invoice URL is protected by a cryptographic view token — a per-invoice HMAC-SHA256 digest derived from the invoice ID, amount, and your private CHECKOUT_SECRET. Without the exact token, the endpoint returns a bare 404 — not even a 403. The invoice's existence is not acknowledged.
Invoice creation itself is gated behind a server-side HMAC signature on the request parameters, meaning only your merchant server with the correct CHECKOUT_SECRET can create invoices. Token comparison always uses hash_equals() — a constant-time comparison that prevents timing attacks from leaking whether a guess was close.
Rate limiting on both invoice creation and status polling is enforced per-IP within configurable time windows, backed by the SQLite database — no Redis or external dependency required.
Ready to own your
infrastructure?
Stop paying 2.9% to processors who ban high-risk businesses without warning. Noctyra deploys this architecture on your VPS in a single day.
Request Deployment