Accept Monero in a small shop with a view-only watcher

One stdlib Python file + monero-wallet-rpc view-only wallet: subaddress per order, paid after 10 confirmations, no spend key on the server.

Accept Monero in a small shop with a view-only watcher (no spend key on the server)

This is the long version of my earlier note. It’s one small Python file (standard library only) that talks to monero-wallet-rpc running a VIEW-ONLY wallet. That wallet can see incoming payments but can never move funds.

Accept XMR payments without putting any spend key on the web server.

Setup

  1. Create a view-only wallet from your main wallet’s primary address and private VIEW key, offline, with monero-wallet-cli (--generate-from-view-key). The spend key stays on your offline machine.
  2. Run monero-wallet-rpc with that wallet, bound to localhost only, with –rpc-login set, pointed at your own node (or a trusted one over Tor).
  3. Each order: new_invoice(url, label) gives a fresh subaddress, so payments never get mixed up between orders.
  4. Poll check(url, minor, amount_atomic) until it returns paid (amount received with >= MIN_CONF confirmations, default 10). Other states: unpaid, partial (underpaid), pending (seen, not yet confirmed).

Guarantees: deduplicates by txid, ignores other subaddresses, counts only confirmed amounts toward paid.
Tests: python3 test_xmrwatch.py (mocked get_transfers data).
Payouts/rewards are deliberately NOT here: use a separate capped hot wallet or a manual approval queue.

The code

"""Minimal view-only Monero invoice watcher (demo for a payment integration).
Talks to monero-wallet-rpc running a VIEW-ONLY wallet: no spend key on the server.
Each invoice gets its own subaddress; paid when received >= amount with >= MIN_CONF confirmations."""
import json, urllib.request
MIN_CONF = 10
def rpc(url, method, params=None):
    body = json.dumps({"jsonrpc": "2.0", "id": "0", "method": method, "params": params or {}}).encode()
    r = json.load(urllib.request.urlopen(urllib.request.Request(url, body, {"Content-Type": "application/json"}), timeout=30))
    if "error" in r: raise RuntimeError(r["error"])
    return r["result"]
def new_invoice(url, label, account=0):
    r = rpc(url, "create_address", {"account_index": account, "label": label})
    return {"address": r["address"], "minor": r["address_index"]}
def invoice_status(transfers, minor, amount_atomic, min_conf=MIN_CONF):
    """transfers: list from get_transfers 'in' (+ 'pool'). Dedup by txid; returns paid/partial/pending/unpaid."""
    seen, conf_sum, any_sum = set(), 0, 0
    for t in transfers:
        if t["subaddr_index"]["minor"] != minor or t["txid"] in seen: continue
        seen.add(t["txid"]); any_sum += t["amount"]
        if t.get("confirmations", 0) >= min_conf: conf_sum += t["amount"]
    if conf_sum >= amount_atomic: return "paid"
    if any_sum >= amount_atomic: return "pending"
    return "partial" if any_sum else "unpaid"
def check(url, minor, amount_atomic):
    r = rpc(url, "get_transfers", {"in": True, "pool": True, "subaddr_indices": [minor]})
    return invoice_status(r.get("in", []) + r.get("pool", []), minor, amount_atomic)

Tested against mocked get_transfers data (dedup, wrong subaddress, partial, pending, paid). Not yet tested on mainnet with a real payment, so try it on stagenet or with a tiny amount first.

If you sell for XMR and want this wired into your shop (order page, fulfilment trigger, email or Nostr DM notice), reply with your setup. Fixed price, paid after a working demo.


Write a comment