$ xmrhost-cli notes show --slug=monero-subaddresses-for-hosting
[$ ] note: monero-subaddresses-for-hosting
// Monero subaddresses for hosting subscriptions — wallet workflows that don't leak the customer base
// 2026-05-03 · diff=intermediate · read=14min · tags=[monero, wallets, payments, privacy, subaddresses] · by=Vex
// ABSTRACT
abstract
A primer on the subaddress mechanism (BIP44-equivalent wallet hierarchy plus stealth addresses), the practical wallet workflows for receiving recurring payments, the view-key transparency story for refunds, and the operational gotchas — sweep behaviour, output-set sizes, and the kinds of mistakes that turn an unlinkable receiving address into a correlation marker. Aimed at anyone building a Monero-only payment surface for a subscription product.
Why this matters
A naïve Monero payment surface is one where every customer pays to the same primary address, the merchant logs incoming transactions by amount + timestamp, and reconciliation is done by matching amounts against pending invoices. It works, until two customers happen to send the same amount in the same window — at which point the merchant has to ask one of them which payment was theirs, which means asking for transaction-level identifying info. It also works until a sufficiently motivated adversary observes the merchant’s primary address and starts correlating the timing of incoming transactions with publicly visible site activity.
The subaddress mechanism, introduced into Monero in mid-2018, fixes both of these. A merchant generates a fresh subaddress per invoice (or per customer); each subaddress can receive payments without revealing that they belong to the same wallet; and the wallet can scan all of them with a single set of keys. The merchant gets a unique receiving address per invoice without any per-address key management. [MRL-0006]MRL-0006 — Improving Obfuscation in the CryptoNote Protocol
This note is about how to actually build that into a hosting-subscription payment surface. It’s at the wallet-and-API level; the upstream cryptography is covered by the Monero whitepaper and the MRL papers cited inline.
A short refresher on the wallet hierarchy
A Monero wallet is rooted in a 256-bit private spend key. From it, the wallet derives:
- A private view key (used to scan the chain for outputs).
- A public spend key and public view key (the two halves of the wallet’s primary address).
- Per-subaddress, a deterministic subaddress private spend / view key pair, indexed by
(account_index, subaddress_index).
The (account, subaddress) index pair is a 32-bit + 32-bit tuple, both starting at zero. The default primary address is (0, 0). Account index 0 is the default account; you can create up to ~4 billion accounts, each with ~4 billion subaddresses, all derivable from the same root spend key. [Monero wallet docs — subaddresses]
Two relevant operational properties:
- Subaddresses look unrelated on-chain. Two outputs sent to two subaddresses of the same wallet cannot be linked by an outside observer who only has chain data. (They CAN be linked by anyone who has the wallet’s private view key — which is the point of the view-key transparency story below.)
- Subaddresses are cheap. Generating one is a single elliptic-curve scalar derivation. The wallet doesn’t need to register them with anyone; they’re just “predicted destinations” that the wallet knows how to scan for.
Wallet setup — monero-wallet-rpc is the right answer
For a payment surface that needs to generate addresses and check balances programmatically, the right tool is monero-wallet-rpc — a long-running daemon that wraps the wallet and exposes a JSON-RPC API. The CLI wallet (monero-wallet-cli) is for human use; the GUI wallet is for desktop use; neither is the right fit for a backend.
Spin up the RPC daemon with the wallet file you’ve created:
Some operational notes on the flags:
--rpc-bind-ip 127.0.0.1— the RPC API is only ever reached from localhost (or via SSH tunnel from the application server). Never expose this to the public internet.--disable-rpc-login— RPC auth is disabled because we’re already gating access at the network layer. If your application server is on a different host, use--rpc-login user:passover a TLS-protected hop instead.--daemon-host/--daemon-port— the address of the operator-runmonerodinstance. Don’t use a public remote node for a payment-receiving wallet; the remote node sees which view-key it’s serving and can, in principle, correlate scanning patterns. Run your own.
Generating a subaddress per invoice
The relevant RPC method is create_address. It takes an account_index (use 0 for the default account; create extra accounts only if you want to compartmentalise customer segments) and an optional label:
{
"id": "0",
"jsonrpc": "2.0",
"result": {
"address": "8AaB...gh4F",
"address_index": 142,
"address_indices": [142]
}
} Store (address_index, address) against the invoice in your database. When the invoice is paid, you’ll match the incoming output by subaddr_index.minor == 142.
Detecting an incoming payment
Polling the wallet daemon is the pragmatic answer. Once per block_time / 2 (so every ~60 seconds for Monero’s 2-minute target block time), call get_transfers with in: true and the address-index filter:
{
"id": "0",
"jsonrpc": "2.0",
"result": {
"in": [
{
"address": "8AaB...gh4F",
"amount": 250000000000,
"confirmations": 11,
"double_spend_seen": false,
"fee": 17320000,
"height": 3019812,
"subaddr_index": { "major": 0, "minor": 142 },
"txid": "9c1e...5b3a",
"type": "in",
"unlock_time": 0
}
]
}
} Mark the invoice paid once confirmations >= 10 (the conservative default; adjust per the value of the order — 6 is fine for sub-$50 orders, 20 for over-$1000).
The view-key transparency story (for refunds and disputes)
A Monero subaddress is unlinkable to outside observers but transparent to anyone who has the wallet’s private view key. This is the property that makes refund flows tractable: when a customer disputes a payment, the merchant can share the per-subaddress view key (or a “transaction proof” — a cryptographic signature that the merchant received a specific output) without revealing the rest of the wallet’s contents. [MRL-0001]MRL-0001 — A Note on Chain Reactions in Traceability in CryptoNote 2.0
The two relevant operations:
1. Per-transaction proof — proves to a third party that a specific transaction credited a specific subaddress. Doesn’t reveal anything about the merchant’s other transactions.
{
"result": {
"signature": "OutProofV2A12bC34dE56fG78hI90jK..."
}
} The customer (or whoever they’re showing it to) verifies the signature against (txid, address, message). If it verifies, the merchant has cryptographic proof that the named transaction credited the named subaddress.
2. Per-subaddress view key — a separate key, derivable per (account, subaddress) tuple, that lets a third party scan the chain for outputs to that specific subaddress without revealing anything about other subaddresses.
The brand’s /payments page documents the view-key transparency offer in customer-facing language; the underlying mechanism is what this note covers.
The refund flow
Monero refunds are operationally awkward because there’s no protocol-level “refund this transaction” primitive. The flow is: the customer provides a refund destination (a regular address or, ideally, a fresh subaddress they generated for the refund), the merchant constructs an outgoing transaction to that destination, and the merchant provides the resulting outgoing-transaction proof to the customer.
1. Customer requests refund.
2. Customer generates a fresh subaddress on their own wallet, sends it
to the merchant via the support channel.
3. Merchant constructs the refund tx via `transfer` RPC, paying the
refund amount minus the network fee.
4. Merchant provides the txid, the customer's destination address, and
the get_tx_proof signature, plus the original incoming-tx proof.
5. Customer verifies both proofs locally — first that the merchant
received the original payment, second that the merchant sent the
refund — and confirms refund completed.
Note that the network fee is the customer’s loss in this model — there’s no protocol mechanism to attach a fee credit to the original payment. Document that explicitly in the refund policy. The brand’s /legal/refund page does this.
The operational gotchas
Sweep behaviour. When a wallet’s “balance” gets large (>1000 outputs in pending state), some operations slow down meaningfully. Periodic sweep_all to consolidate outputs is the operator hygiene; do it during low-activity windows. The risk: a sweep_all consolidates many subaddresses’ outputs into one larger output, which then is correlatable to the union of those subaddresses if the sweep destination is shared with other use. Send sweep outputs to a fresh internal subaddress, never to the primary address.
Output-set sizes. Monero’s transaction-mixing parameter (the ring size, currently 16) shapes the practical anonymity-set. The mathematics are well-covered upstream; the operational implication is that your wallet’s outgoing transactions are strongly recommended to be made when the chain has enough decoy candidates, which in practice means: don’t spend an output that’s under 10 minutes old, and don’t spend an output with an unusual amount (e.g. exact multiples of 1 XMR — split before spending).
Cold storage. A payment-receiving wallet on an internet-connected box is a hot wallet. Periodically, sweep the bulk of the balance to a cold-storage wallet (a wallet whose spend key never touches an internet-connected machine). The brand’s /docs/monero-cold-storage-workflow entry walks through the air-gapped signing dance; the short version is: use monero-wallet-cli --offline on an air-gapped machine, sign transactions there, transfer the signed tx via QR or USB to an internet-connected monerod for broadcast.
The payment-id legacy. Older Monero wallets used “payment IDs” — a separate identifier tag attached to a transaction — to disambiguate payments to the same address. Subaddresses obsoleted this entirely. Don’t use payment IDs in any new system; if you receive a transaction with one, it’s almost certainly from a wallet that hasn’t been updated since 2018 and is sending to your primary address. Treat it as a correctness-only signal, not as a security guarantee. [Monero v0.13 (Beryllium) release notes — subaddresses replace payment IDs]
A worked example: the per-invoice flow end to end
1. Customer clicks "buy vps-2 plan" on the site.
2. Backend creates an invoice row:
invoice_id = uuidv4()
amount_xmr = (plan_price_usd / xmr_usd_rate) * 1.005 // small slack for rate drift
status = "awaiting_payment"
3. Backend calls create_address with a label including the invoice_id.
Stores (invoice_id, address, address_index, amount_xmr).
4. Backend renders the checkout page with the address + amount as
an animated QR code. Customer scans, pays.
5. Polling worker (every 60s) runs get_transfers for the address_index.
When confirmations >= 10 AND amount >= amount_xmr * 0.997 (tolerance
for rate drift between invoice and payment time), marks invoice paid.
6. Provisioning worker picks up the paid invoice, kicks off provisioning.
7. After 24h, sweep the subaddress's outputs to an internal "received"
subaddress (different from the invoice subaddress). The original
subaddress is retained but not reused — its address_index stays in
the database forever, because we may need to issue refund proofs
against it later.
That’s the entire merchant-side flow. There’s no payment processor in the loop, no chargeback risk, no settlement window, and no information leaked to a third party in the normal happy path.
Closing — the simple story is the right story
The story to tell customers is: every invoice gets its own address; we share view-key proofs on request for any specific transaction; we don’t reuse addresses; we run our own node so no third party sees what our wallet is scanning for. That’s the entire surface, and it composes with the rest of the brand’s payment-stance content on /payments and /why-monero.
// END OF NOTE
$ cd /notes # back to the listing