XMR payment gateway
  • Java 93.1%
  • HTML 6.5%
  • Python 0.4%
Find a file
2026-07-28 11:14:24 +02:00
.idea Add Monero payment gateway with audit ledger and management panel 2026-07-26 22:15:47 +02:00
src Move the management panel to com.caishenpay.webadmin 2026-07-26 22:32:35 +02:00
tools Add Monero payment gateway with audit ledger and management panel 2026-07-26 22:15:47 +02:00
.gitignore Add Monero payment gateway with audit ledger and management panel 2026-07-26 22:15:47 +02:00
API_GUIDE.md Add Monero payment gateway with audit ledger and management panel 2026-07-26 22:15:47 +02:00
pom.xml update pom 2026-07-28 11:13:46 +02:00
PYTHON_INTEGRATION_EXAMPLE.md add py integration examples 2026-07-28 11:14:24 +02:00
README.md Move the management panel to com.caishenpay.webadmin 2026-07-26 22:32:35 +02:00
SETUP_GUIDE.md Add Monero payment gateway with audit ledger and management panel 2026-07-26 22:15:47 +02:00
USERGUIDE.md Move the management panel to com.caishenpay.webadmin 2026-07-26 22:32:35 +02:00

Caishen Pay

A crypto payment gateway API. A billing service calls it to create an invoice, shows the payer an address and amount (or a QR code), and polls until the invoice settles or expires.

Currently settles Monero, by deriving a fresh subaddress per invoice. The pipeline above the wallet is currency-agnostic; adding a coin is one interface implementation.

  • Java 21, Spring Boot 3.5, SQLite (Hibernate + community dialect)

  • Monero via monero-java against monero-wallet-rpc

  • Requests authenticated with a symmetric-key HMAC; the audit ledger sealed with a second key

  • Setting it up for the first time? SETUP_GUIDE.md — wallet creation, choosing and connecting a node, and end-to-end verification.

  • Running it? USERGUIDE.md — wallet setup, configuration, monitoring, backups, reconciling over/underpayments, and troubleshooting.

  • Integrating a client? API_GUIDE.md — worked request/response examples for every success and error case, a signing self-test vector, and a reference client.

Layout

model/       Invoice, LedgerEntry, currencies, exact-amount conversion
repository/  Spring Data repositories
service/     invoice lifecycle, audit ledger, payment monitor, health
service/wallet/      CryptoWallet — the extension point for new coins
service/wallet/xmr/  the Monero implementation
security/    HMAC request signing, replay guard, auth filter
web/         controllers, DTOs, error mapping

An optional read-only management panel lives separately in com.caishenpay.webadmin, served at /management/v1 and disabled by default. It depends on the payment core one way; the core contains no reference to it. See USERGUIDE.md §12.

Running it

monero-wallet-rpc must already be running with the receiving wallet open and a daemon connected. The gateway only derives addresses and reads transfers — it never issues a spend, so these credentials alone cannot move funds.

export CAISHEN_API_HMAC_SECRET=...      # shared with the billing service; >= 32 chars
export CAISHEN_LEDGER_HMAC_SECRET=...   # gateway only, never shared; >= 32 chars
export CAISHEN_XMR_RPC_URI=http://127.0.0.1:18083
export CAISHEN_DB_PATH=/var/lib/caishenpay/caishenpay.db

mvn package && java -jar target/processor-1.0-SNAPSHOT.jar

The gateway refuses to start if either secret is missing or under 32 characters, and refuses if they are the same value. Full configuration lives in src/main/resources/application.yml.

Setting Default Meaning
caishen.invoice.ttl 20m Payment window advertised to the payer
caishen.invoice.settlement-grace 30m Extra time for funds that reached the mempool before expiry
caishen.monitor.poll-interval 15s How often open invoices are checked
caishen.monitor.batch-size 200 Open invoices examined per pass
caishen.security.max-clock-skew 120s Accepted age of a request timestamp
caishen.xmr.min-confirmations 1 Confirmations before funds count towards an invoice
caishen.xmr.account-index 0 Wallet account the subaddresses are derived under

Authentication

Every endpoint except GET /api/v1/health requires three headers. The filter denies by default, so any endpoint added later is authenticated without anyone remembering to list it.

Header Value
X-Caishen-Timestamp Unix seconds
X-Caishen-Nonce 864 chars of [A-Za-z0-9._~-], fresh per request (a UUID works)
X-Caishen-Signature Lower-case hex HMAC-SHA256 of the string below, keyed with the API secret
METHOD \n PATH_WITH_QUERY \n UNIX_SECONDS \n NONCE \n SHA256_HEX(BODY)

Signing the body prevents an intermediary from altering an amount in flight; signing the method and path stops a captured signature being aimed elsewhere; the timestamp bounds how long a captured request is usable; the nonce keeps two genuinely different requests distinct — without it, two invoices for the same amount in the same second would be indistinguishable from a replay.

A used signature is refused for state-changing methods, so a replayed POST /invoices cannot mint a duplicate invoice. Safe methods are not tracked, so status polling is never rate-limited by this.

import hashlib, hmac, time, uuid, urllib.request

def signed(method, path, body=b'', secret=b'...'):
    ts, nonce = int(time.time()), uuid.uuid4().hex
    canonical = f"{method}\n{path}\n{ts}\n{nonce}\n{hashlib.sha256(body).hexdigest()}"
    req = urllib.request.Request('http://gateway' + path, data=body or None, method=method)
    req.add_header('Content-Type', 'application/json')
    req.add_header('X-Caishen-Timestamp', str(ts))
    req.add_header('X-Caishen-Nonce', nonce)
    req.add_header('X-Caishen-Signature',
                   hmac.new(secret, canonical.encode(), hashlib.sha256).hexdigest())
    return urllib.request.urlopen(req)

Endpoints

POST /api/v1/invoices

{"amount": 1.5, "currency": "XMR"}

amount is in whole coins and may be sent as a JSON number or string; it is parsed exactly, never as floating point. More precision than the coin can settle (past 12 decimals for XMR) is rejected rather than rounded. Responds 201:

{
  "invoice_id": "9f1c...",
  "currency": "XMR",
  "amount": "1.500000000000",
  "amount_atomic": "1500000000000",
  "amount_paid": "0.000000000000",
  "address": "8Asn91rz...",
  "payment_uri": "monero:8Asn91rz...?tx_amount=1.5",
  "status": "PENDING",
  "created_at": "2026-07-26T16:19:03.353Z",
  "expires_at": "2026-07-26T16:39:03.353Z",
  "closed_at": null
}

payment_uri encodes address and amount together and is what belongs in the QR code — a payer who retypes a 95-character address and an amount by hand is a payer who eventually underpays.

GET /api/v1/invoices/{invoice_id}

The same body, reflecting current state. PENDINGCOMPLETED or EXPIRED; terminal statuses never change again. Unknown and malformed ids both return 404, so the endpoint cannot be used to learn which ids are well formed.

GET /api/v1/health

Unauthenticated, for load balancers. 200 {"status":"UP"}, or 503 with "status":"DEGRADED" and a list of problems. It answers from state the monitor has already published — no wallet call, no database query — so it cannot be used as a lever against either.

It reports DEGRADED when the wallet is unreachable and when monitoring passes have stopped arriving. A gateway that has silently stopped watching for payments otherwise looks identical to a working one from outside.

Errors

Uniform body {"error": "...", "message": "..."}. Codes: invalid_amount, validation_error, malformed_request, unsupported_currency, unauthorized, payload_too_large, invoice_not_found, wallet_unavailable, conflict, internal_error. No handler echoes a message the gateway did not author, so driver and framework internals cannot leak through an error body.

How settlement is decided

Each pass loads open invoices oldest first, asks the wallet about all of their addresses in one batched call, and reconciles each one. Wallet calls never happen inside a database transaction.

For each invoice, in this order:

  1. Paid? Confirmed funds ≥ the requested amount → COMPLETED. Checked before expiry, so a payment that confirms in the same pass the window closes settles rather than being lost.
  2. Expired? Past expires_atEXPIRED, unless enough money is already visible on chain but not yet confirmed. In that case the invoice is held open until the settlement deadline; expiring someone who paid at minute 19 and stranding their funds is the most expensive mistake available here.
  3. Otherwise it stays PENDING.

Deliberate choices worth knowing about:

  • An unreachable wallet never expires anything. It cannot prove a payment did not arrive, so affected invoices stay PENDING, one WALLET_UNAVAILABLE entry is written per failed pass, and health goes DEGRADED. They need an operator, not a guess.
  • A wallet that omits an address it was asked about is treated as "unknown", not "unpaid."
  • Recorded payment never decreases. A wallet mid-reorg can briefly report less than it did a moment ago; believing it would un-pay a paid invoice.
  • Overpayment completes the invoice and records the excess separately for reconciliation.
  • Partial funds on an expiring invoice are recorded as UNDERPAYMENT_ABANDONED with the address, since that money is real and sitting somewhere.
  • Once terminal, an invoice is never re-read from the wallet again.

The ledger

Every payment-related action is appended to ledger: creation, address assignment, detected payments, completion, overpayment, expiry, stranded funds, and monitoring gaps. Entries are never updated or deleted.

Each entry carries a keyed digest over its own contents and the previous entry's digest, using the ledger secret — which API clients do not hold. Altering, reordering, back-dating or deleting history therefore takes more than write access to the database file, and any such change is detected by LedgerService.verifyChain(). Fields are length-prefixed before hashing, so free text cannot be crafted to impersonate neighbouring fields.

Audit entries are written with Propagation.MANDATORY: they commit or roll back with the state change they describe. A ledger that can disagree with the invoice table is worse than no ledger.

Storage

SQLite in WAL mode with synchronous=FULL — payments must survive an unclean shutdown. The connection pool is deliberately one connection: SQLite permits a single writer, so serialising in the pool avoids SQLITE_BUSY entirely. That is also why no service method holds a transaction open across a wallet RPC call.

schema.sql owns the schema and Hibernate is set to validate. Hibernate's SQLite dialect silently drops unique constraints, and "two invoices must never share a receiving address" has to be enforced by the database rather than merely intended by the mapping. Changing an entity without changing schema.sql fails at startup. Schema changes on an existing database need a migration written by hand.

Amounts are stored as decimal text and handled as BigInteger atomic units throughout; SQLite's signed 64-bit INTEGER cannot hold the top of Monero's range, and NUMERIC can fall back to REAL. Timestamps are fixed-width UTC text so they sort correctly as strings.

Tests

mvn test

318 tests. Alongside the ordinary paths they cover the ones that cost money: expiry to the millisecond, a payment confirming exactly at expiry, reorg-shrunk balances, one atomic unit short, overpayment, mempool funds held through the grace period, an unreachable wallet during expiry, a wallet that omits an address, concurrent reconciliation of one invoice, ledger tampering (altered amounts, deleted entries, reordered chains, forged digests), signature forgery, replay, tampered bodies/paths/methods/nonces, clock skew at the boundary, and oversized bodies. The panel adds its own: that it is absent when disabled, that an API key cannot open it and a panel session cannot open the payment API, that its database connection is refused writes, and that it accepts no state-changing method.

FakeWallet stands in for the backend so failure modes that cannot be provoked against a real node are exercised directly. MutableClock makes time boundaries exact rather than approximate.