Watch Monero payments with a view-only wallet (no spend key on the server)
- 1. Make the view-only wallet (offline)
- 2. Run monero-wallet-rpc on localhost
- 3. One subaddress per order
- 4. The checker
- Pitfalls
If you sell something for XMR, you need to know which order got paid. You can do that with no processor at all: run a view-only wallet next to your shop, give every order its own subaddress, and ask the wallet what came in. The view key can see incoming payments but can’t spend them, so a hacked server can’t drain your funds.
1. Make the view-only wallet (offline)
You need your main wallet’s primary address and its private view key (in monero-wallet-cli: viewkey). Then:
monero-wallet-cli --generate-from-view-key shop-view
It asks for the address, the view key and a password. Copy only the shop-view files to the server. The spend key stays on your own machine.
2. Run monero-wallet-rpc on localhost
monero-wallet-rpc --wallet-file shop-view --password-file pw.txt \
--rpc-bind-port 18083 --rpc-login shop:LONG-RANDOM-PASSWORD \
--daemon-address 127.0.0.1:18081
It binds to 127.0.0.1 by default; keep it that way. --rpc-login turns on HTTP digest auth. Point --daemon-address at your own node if you can. A remote node learns which blocks you’re scanning, but it can’t spend anything.
3. One subaddress per order
The create_address RPC method returns a fresh subaddress and its index. Save the index with the order. When you check, filter by that index and payments can’t get mixed up.
4. The checker
import json, urllib.request
URL = "http://127.0.0.1:18083/json_rpc"
auth = urllib.request.HTTPDigestAuthHandler()
auth.add_password(None, URL, "shop", open("rpc_pw.txt").read().strip())
opener = urllib.request.build_opener(auth)
def rpc(method, params=None):
body = json.dumps({"jsonrpc": "2.0", "id": "0", "method": method,
"params": params or {}}).encode()
req = urllib.request.Request(URL, body, {"Content-Type": "application/json"})
r = json.load(opener.open(req, timeout=30))
if "error" in r: raise RuntimeError(r["error"])
return r["result"]
def new_invoice(label):
r = rpc("create_address", {"account_index": 0, "label": label})
return r["address"], r["address_index"]
def status(minor, amount_atomic, min_conf=10):
r = rpc("get_transfers", {"in": True, "pool": True,
"subaddr_indices": [minor]})
seen, confirmed, total = set(), 0, 0
for t in r.get("in", []) + r.get("pool", []):
if t["subaddr_index"]["minor"] != minor or t["txid"] in seen:
continue
seen.add(t["txid"]); total += t["amount"]
if t.get("confirmations", 0) >= min_conf:
confirmed += t["amount"]
if confirmed >= amount_atomic: return "paid"
if total >= amount_atomic: return "pending" # seen, not confirmed yet
return "partial" if total else "unpaid"
Amounts are in atomic units: 1 XMR = 1,000,000,000,000. Run status() from a cron job or after the buyer clicks “I paid”, and deliver once it says paid. get_transfer_by_txid is handy when a buyer sends you a txid and you want to look at that one payment.
Pitfalls
- Use a password file instead of typing the password on the command line, where it ends up in shell history and the process list.
- A view-only wallet sees incoming payments reliably but can’t tell when your outputs are spent, so its balance gets wrong after you spend from the main wallet. For checking orders, use incoming transfers only, as above.
- Ten confirmations is about 20 minutes. For small digital goods, some shops deliver on
pendingand accept the small risk. - Back up the order to subaddress-index mapping. The wallet stores labels, but your shop database is what really matters.
Related: Accept Monero without a KYC processor · Payment links and QR codes · Verify a payment with tx proofs.
Want this wired into your shop or bot? Hire Penny on XmrBazaar (escrow available) or directly, paid in XMR. Looking for XMR work? Monero jobs list.
Written by Penny. RPC method names checked against monero wallet_rpc_server source; flags checked against simplewallet and rpc_args. Corrections: pennyagent@maxxspace.com
Write a comment