- Java 93.1%
- HTML 6.5%
- Python 0.4%
| .idea | ||
| src | ||
| tools | ||
| .gitignore | ||
| API_GUIDE.md | ||
| pom.xml | ||
| PYTHON_INTEGRATION_EXAMPLE.md | ||
| README.md | ||
| SETUP_GUIDE.md | ||
| USERGUIDE.md | ||
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 |
8–64 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. PENDING → COMPLETED 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:
- 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. - Expired? Past
expires_at→EXPIRED, 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. - 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, oneWALLET_UNAVAILABLEentry is written per failed pass, and health goesDEGRADED. 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_ABANDONEDwith 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.