$ xmrhost-cli docs show --topic=monero-subaddress-workflow
[$ ] doc: monero-subaddress-workflow
// Monero subaddress workflow for hosting operators — generate, scan, sweep with monero-wallet-cli
// published=2026-04-29 · updated=2026-04-29 · diff=advanced · read=22min · tags=[monero, xmr, subaddress, wallet, payment]
// ABSTRACT
abstract
Procedure for the operator-side Monero accounting on a XMRHost VPS-by-VPS basis. Covers the subaddress derivation model (one major-account per product line, one subaddress per customer), the watch-only wallet on the VPS that scans incoming payments without holding the spend key, the daily scan + sweep job, the secret-key generation on a cold workstation, the view-key delegation pattern that lets the VPS see incoming-only without having spend authority, and the four operational checks that catch the common failure modes (clock drift, daemon out-of-sync, view-key mismatch, sweep-to-cold-wallet failed).
Scope and assumptions
This is the operator-side procedure for accepting Monero on a XMRHost VPS billing surface — the workflow that sits behind the brand checkout’s “pay in XMR” button. It is opinionated: the spend key never touches the VPS, the VPS runs a watch-only wallet derived from the public view key, and a daily cron job sweeps any received funds to a cold-wallet subaddress on a workstation the operator controls.
This procedure is the brand’s choice. There are simpler alternatives — a hot wallet on the VPS, a custodial gateway (BTCPay-Server’s Monero plugin), a payment processor — each with its own trade-offs. The brand’s choice is the cold-spend / hot-receive split because it minimises the worst-case loss from a VPS compromise to “the funds received since the last sweep” rather than “the entire wallet balance”.
Baseline assumptions:
- Debian 12 (bookworm) on a XMRHost VPS in Iceland, Romania, or Switzerland.
- The brand’s
hardened-by-defaultbaseline is in place — seeharden-sshdandkernel-hardening-checklist. - A separate cold workstation (offline most of the time, online only for the periodic sweep) where the spend key lives. The brand recommends a Tails-booted laptop or a hardware wallet (Ledger or the Monero-specific Cake Wallet hardware device).
- ≥ 100 GiB of disk on the VPS — the Monero blockchain is ~150 GiB pruned, ~250 GiB full, and grows ~5 GiB/month. The brand uses a pruned daemon by default; full daemon only when the operator wants to also serve as a public node.
- No operator illusions about Monero providing total privacy. The cryptography is good but the operational surface (timing, network metadata, the cold/hot wallet split itself) leaks information that the operator has to manage. The MRL papers are the canonical reading. [MRL-0001]A Note on Chain Reactions in Traceability in CryptoNote 2.0
This doc does NOT cover Monero merchant gateways like BTCPay or NowPayments — those are simpler but have a different trust model the brand explicitly rejects.
Step 1 — install monerod, NOT the GUI
The Monero project distributes static binaries that are reproducible-built from the public source. [getmonero.org/downloads — release signing keys] The Debian-shipped monero package lags upstream and shouldn’t be used for an operator-grade install.
# Workstation note: do this download on a trusted machine first, verify the
# signature, then transfer to the VPS. Don't blindly download to the VPS.
# On a workstation, fetch the latest release tarball + signature.
LATEST=$(curl -s https://www.getmonero.org/downloads/ | grep -oE 'monero-linux-x64-v[0-9.]+\.tar\.bz2' | head -1)
wget "https://downloads.getmonero.org/cli/${LATEST}"
wget "https://downloads.getmonero.org/cli/${LATEST}.asc"
# Verify against the binaryFate signing key (key fingerprint in the project
# README; pin it in your local keyring).
gpg --verify "${LATEST}.asc" "${LATEST}"
# Should print: "Good signature from binaryFate ..."
# Compare the SHA256 against the value in hashes.txt published by the project.
sha256sum "${LATEST}"
curl -s https://www.getmonero.org/downloads/hashes.txt | grep "${LATEST}"
# The two SHA256 values must match exactly.
# Transfer to the VPS over SCP, then extract.
scp "${LATEST}" root@<vps-ip>:/tmp/
On the VPS:
cd /opt
tar xf /tmp/monero-linux-x64-v*.tar.bz2
mv monero-* monero
ln -sf /opt/monero/monerod /usr/local/bin/monerod
ln -sf /opt/monero/monero-wallet-cli /usr/local/bin/monero-wallet-cli
ln -sf /opt/monero/monero-wallet-rpc /usr/local/bin/monero-wallet-rpc
monerod --version
Expected:
Monero 'Fluorine Fermi' (v0.18.3.4-release)
Step 2 — the canonical monerod config
Drop into /etc/monero/monerod.conf:
# /etc/monero/monerod.conf — XMRHost pruned monerod for an accounting VPS.
# ---------- data ----------------------------------------------------------
data-dir = /var/lib/monero
log-file = /var/log/monero/monerod.log
log-level = 1 # WARN. Raise transiently for debug.
max-log-files = 5
max-log-file-size = 100000000 # 100 MB rotation.
# ---------- pruning -------------------------------------------------------
# 2/3 pruning — keeps validator-required state, prunes the bulk of historical
# block data. ~150 GB on disk vs. ~250 GB unpruned. The watch-only wallet
# can scan with a pruned daemon as long as the wallet's restore-height is
# AFTER the pruning seed, which we set in step 4.
prune-blockchain = 1
# ---------- p2p network ---------------------------------------------------
p2p-bind-ip = 0.0.0.0
p2p-bind-port = 18080
out-peers = 32
in-peers = 32
# ---------- rpc (LOCALHOST ONLY for the wallet on this box) --------------
# RPC bound to localhost only — the wallet on this box talks to it. No
# external RPC; no public-monerod role.
rpc-bind-ip = 127.0.0.1
rpc-bind-port = 18081
restricted-rpc = 1
public-node = 0
# ---------- ZMQ pub-sub (DISABLED) ---------------------------------------
# ZMQ would be useful for a real-time payment notifier; we don't use it
# (the daily cron is fine for billing accounting). Keep off to reduce
# attack surface.
zmq-rpc-bind-ip = 127.0.0.1
zmq-rpc-bind-port = 18082
no-zmq = 1
# ---------- privacy / security -------------------------------------------
# Refuse fluffy blocks from non-trusted peers (default-on in modern monerod
# but be explicit).
enable-dns-blocklist = 1
no-igd = 1 # don't try UPnP.
no-sync = 0
Set up the systemd unit (the binary tarball doesn’t include one; write our own):
useradd -r -s /usr/sbin/nologin -d /var/lib/monero monero
mkdir -p /var/lib/monero /var/log/monero /etc/monero
chown monero:monero /var/lib/monero /var/log/monero
mv /etc/monero/monerod.conf /etc/monero/ # if you wrote it elsewhere
cat > /etc/systemd/system/monerod.service <<'EOF'
[Unit]
Description=Monero daemon (pruned)
After=network-online.target
Wants=network-online.target
[Service]
User=monero
Group=monero
ExecStart=/usr/local/bin/monerod --config-file /etc/monero/monerod.conf --non-interactive
Restart=on-failure
RestartSec=10
LimitNOFILE=16384
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/monero /var/log/monero
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now monerod
# Open the firewall for p2p only.
nft add rule inet filter input tcp dport 18080 accept
Watch the initial sync — pruned takes 12-24 hours on a 1 Gbps VPS:
journalctl -u monerod -f | grep -i 'height\|sync'
Expected milestone: SYNCHRONIZED OK once the daemon has caught up to the network top.
Step 3 — generate keys on the COLD workstation, not the VPS
This is the critical step. The spend key (mnemonic seed) is generated on a workstation that is OFFLINE — Tails-booted laptop, air-gapped Raspberry Pi, hardware wallet. The view key derived from it can safely live on the VPS; the spend key must NEVER.
On the cold workstation (using monero-wallet-cli or a hardware wallet’s interface):
# In monero-wallet-cli, COLD workstation:
monero-wallet-cli --generate-new-wallet xmrhost-cold --offline
Interactive prompts:
- Wallet password — long passphrase, written down on paper, stored in the operator’s offline backup.
- Mnemonic seed — 25-word seed shown ONCE. Write down on paper, do NOT photograph, do NOT type into any computer connected to the internet.
- Restore height — pick the current Monero block height at wallet creation (from a block explorer; the precision matters because it’s the start point for the watch-only wallet’s scan).
The wallet creates xmrhost-cold (encrypted on disk), xmrhost-cold.keys (the keyfile), xmrhost-cold.address.txt (the public primary address). Confirm in the CLI:
[wallet 47abc...]: address
0 47abc... Primary
The 95-character base58 string starting with 4 is the public primary address. Note it — you’ll need both this AND the public view key for the next step.
Extract the view key (this is safe to share; it is what enables read-only scanning of the wallet’s history):
[wallet 47abc...]: viewkey
secret: <DO-NOT-EXPORT-THIS>
public: a1b2c3d4e5f6... (64 hex chars)
Write down BOTH (pen + paper). The PUBLIC view key + the public address go to the VPS in step 4. The SECRET view key stays on the cold workstation but is a less-sensitive secret than the spend key — enabling read access vs spend access.
Step 4 — create the watch-only wallet on the VPS
On the VPS, create a watch-only wallet from the public address + public view key:
sudo -u monero monero-wallet-cli \
--generate-from-keys xmrhost-watch \
--restore-height <current-block-height> \
--address <PUBLIC-ADDRESS-FROM-STEP-3> \
--viewkey <SECRET-VIEW-KEY-FROM-STEP-3> \
--daemon-address 127.0.0.1:18081
Wait, secret view key on the VPS? Yes — the SECRET view key is what the watch-only wallet needs to decrypt incoming-payment ECDH-shared secrets on-chain. [MRL-0006]A Note on Reusing Spend Keys The secret view key alone CANNOT spend; it can only see. Treat it as sensitive (encrypt the wallet file at rest) but not catastrophic.
Confirm the wallet syncs:
Refreshed 3145000/3145000 (network height 3145000) Watch-only wallet: synchronized.
Step 5 — generate one subaddress per customer
Subaddresses are the per-payment receive addresses. The watch-only wallet generates them deterministically (same view-key derivation that the cold wallet uses), so the VPS can issue a fresh subaddress for each customer order without round-tripping to the cold wallet.
# In monero-wallet-cli, watch-only wallet on the VPS:
[wallet 47abc...]: address new "order-1042"
1 82xyz... order-1042
[wallet 47abc...]: address new "order-1043"
2 82pqr... order-1043
Each subaddress is bound to a label (the order ID). The brand integration shells out from the checkout backend to issue one subaddress per order at order-creation time, stores the subaddress in the order record, and the customer pays to that address. The watch-only wallet’s incoming_transfers command shows incoming payments per subaddress.
For programmatic use, run monero-wallet-rpc instead of the interactive CLI:
monero-wallet-rpc \
--wallet-file xmrhost-watch \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port 18083 \
--disable-rpc-login \
--daemon-address 127.0.0.1:18081
Then issue subaddresses via JSON-RPC:
curl -X POST http://127.0.0.1:18083/json_rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"0","method":"create_address","params":{"account_index":0,"label":"order-1042"}}'
Response:
{"id":"0","jsonrpc":"2.0","result":{"address":"82xyz...","address_index":1}}
Step 6 — the daily scan + sweep cron job
The watch-only wallet on the VPS sees incoming payments. To sweep the funds to the cold wallet, the cold workstation periodically:
- Pulls the export of the watch-only wallet’s outputs to date.
- Imports them into the cold wallet (which now has full visibility of the on-chain state).
- Constructs an outgoing transaction sweeping the balance to a cold-storage subaddress.
- Exports the signed transaction.
- Imports the signed tx back to the watch-only wallet on the VPS, which broadcasts.
This is the canonical Monero cold-spend flow. [Monero docs — cold/hot wallet workflow] The brand cadence: daily, by hand, on the cold workstation.
The watch-only export step (run on the VPS):
# Export outputs from the watch-only wallet — produces a binary blob
# that the cold wallet imports.
monero-wallet-cli --wallet-file xmrhost-watch \
--command export_outputs xmrhost-watch.outputs.bin
Transfer xmrhost-watch.outputs.bin to the cold workstation via USB or QR code. On the cold workstation:
monero-wallet-cli --wallet-file xmrhost-cold --offline \
--command import_outputs xmrhost-watch.outputs.bin
# After import, construct the sweep tx — send everything to cold-storage
# subaddress index 0 of the cold wallet.
monero-wallet-cli --wallet-file xmrhost-cold --offline \
--command sweep_all <COLD-STORAGE-SUBADDRESS> 5
The 5 is the priority (1=low, 5=highest) that Monero uses to set the per-byte fee. For a once-daily sweep, priority 1 is fine.
Export the unsigned tx:
[wallet 47abc...]: sign_transfer multi-sig.unsigned xmrhost-cold.signed.tx
Transfer xmrhost-cold.signed.tx BACK to the VPS, then submit:
monero-wallet-cli --wallet-file xmrhost-watch \
--command submit_transfer xmrhost-cold.signed.tx
The watch-only wallet broadcasts the signed transaction; the cold wallet’s spend authority was used only on the cold workstation, never exposed to the network.
Step 7 — four operational checks
Check 1 — monerod synced
curl -s -X POST http://127.0.0.1:18081/json_rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"0","method":"sync_info"}' \
| python3 -m json.tool | head -10
Expected: target_height equals height. If diverging by > 100 blocks, the daemon is behind — typically a clock issue or upstream peer-discovery problem.
Check 2 — clock skew
chronyc tracking | grep -E 'Stratum|System time'
Monero rejects blocks more than 7200 seconds (2 hours) skewed; the network just won’t sync if your clock drifts. Stratum ≤ 3, sub-second deviation.
Check 3 — view key matches
On the watch-only wallet:
[wallet 47abc...]: viewkey
secret: <should-match-step-3-on-cold-workstation>
public: <should-match-step-3>
Both must match exactly. If they don’t, the watch-only wallet was created with a typo in the view key — recreate from scratch (the wallet file is just a key derivation; nothing on-chain to lose).
Check 4 — last sweep landed
After running the daily sweep cycle, confirm on a public block explorer that the sweep transaction is confirmed (Monero block explorers show transactions by tx hash; the brand uses xmrchain.net).
Closing — what to do next
The Monero accounting flow is operational. Order of next steps:
- Set up a monitoring scrape on
monerod’s RPC forblock_heightlag — alert if > 50 blocks behind network top for > 30 minutes. - Test the cold-spend flow end-to-end with a small (< 0.01 XMR) amount BEFORE the first real customer payment arrives. The flow has enough moving parts that a dry-run reveals the cold/hot file-transfer issues you’d otherwise hit at the worst possible moment.
- Consider running monerod on a separate VPS from the watch-only wallet — the daemon does not need to be co-tenant with the wallet (the wallet talks to it over RPC). Splitting reduces blast radius if the wallet VPS is compromised.
- Read the MRL papers on transaction graph analysis to set realistic operator expectations of the privacy provided. [MRL-0007]Sets of Spent Outputs
The brand recommendation is unambiguous: spend key on the cold workstation, view key on the VPS, sweep daily, never get clever. Most operator losses in this space come from getting clever.
// END OF DOC
$ cd /docs # back to the manual