[$ xmrhost] _

$ 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-default baseline is in place — see harden-sshd and kernel-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:

monerod --version
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:

monero-wallet-cli --wallet-file xmrhost-watch --daemon-address 127.0.0.1:18081 --command status
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:

  1. Pulls the export of the watch-only wallet’s outputs to date.
  2. Imports them into the cold wallet (which now has full visibility of the on-chain state).
  3. Constructs an outgoing transaction sweeping the balance to a cold-storage subaddress.
  4. Exports the signed transaction.
  5. 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:

  1. Set up a monitoring scrape on monerod’s RPC for block_height lag — alert if > 50 blocks behind network top for > 30 minutes.
  2. 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.
  3. 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.
  4. 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