Anatomy of a non-custodial Monero tip bot on Nostr
Anatomy of a non-custodial Monero tip bot on Nostr
Tipping creators in XMR is the obvious use case: private, no KYC, no exchange
account. But every “tip bot” I’ve looked at is custodial — it holds your
spend key or sits between you and the money. For a privacy coin that’s
backwards. Here’s how to build one that never touches funds: it verifies
payments with the recipient’s view key only.
The custodial trap
Most tip integrations work like this: the user sends to an address the bot
controls, and the bot later forwards the funds. That means the bot holds the
spend key. You’ve swapped “trust the platform” for “trust the bot” — a smaller
target, but still a trusted third party that can disappear with the balance.
That’s not what Monero is for.
The non-custodial model
The recipient keeps their own wallet. The bot becomes a verifier: it reads
incoming payments with the private view key (the spend key never leaves the
wallet and never touches the bot), confirms the amount arrived, and
acknowledges the tipper on Nostr. No block explorer, no full node, no third
party that learns your IP and address.
The view key is the key insight. It lets anyone see incoming transfers and
amounts to an address, but it can’t spend a single piconero. So a verifier
built on the view key is the Monero equivalent of a read-only bank statement —
useful, but impossible to abuse for theft.
Architecture
tipper Nostr relays tip bot (verifier)
| | |
|-- monero: URI -------->| (note w/ p-tag) |
| (pays via wallet) | |
| |<-- listens for tip intents --|
| | |
| | extract addr + amount |
| | | |
| | v |
| | monero-wallet-rpc |
| | (view key ONLY) |
| | | |
| | v |
| | public node (cakewallet) |
| | | |
|<-- "received, verified"|<- confirmation note ---------|
Every arrow except the actual Monero payment is a Nostr note. The payment
itself is a normal Monero transaction the tipper makes from their own wallet —
nothing custodial about it.
The payment-verification flow
- The tipper copies a
monero:URI the bot published (address +tx_amounttx_description).
- The tipper pays from their own wallet. That’s an ordinary Monero tx.
- The bot receives a Nostr note mentioning its npub with a tip intent
(address + amount). - The bot calls
monero-wallet-rpcget_transfers(in: true)on a
view-only wallet — address plus private view key, no spend key —
pointed at a public node (e.g.xmr-node.cakewallet.com:18081). - The bot matches the incoming transfer (amount + note/txid) and posts a
confirmation note. The confirmation is produced from the view key alone: it
can see the amount arrived, but it cannot move funds.
The monero: URI
Built offline and deterministically, so there’s no server in the loop:
def build_monero_uri(address, amount_xmr=None, description=None, name=None):
if not is_valid_xmr_address(address):
raise ValueError("invalid Monero address")
uri = "monero:" + address
params = []
if amount_xmr is not None:
amt = ("%.12f" % float(amount_xmr)).rstrip("0").rstrip(".")
params.append("tx_amount=" + amt)
if description:
params.append("tx_description=" + _uri_encode(description))
if name:
params.append("recipient_name=" + _uri_encode(name))
if params:
uri += "?" + "&".join(params)
return uri
Address validation uses the maintained monero library, which accepts standard
(95-char), subaddress (106-char) and integrated (106-char) mainnet addresses —
not a hand-rolled checksum that might lie to you.
USD ↔ XMR peg
Tiers can be pegged so “tip $1” always requests the right amount of XMR at the
moment of the request:
def usd_to_xmr(usd, xmr_usd_rate):
if float(xmr_usd_rate) <= 0:
raise ValueError("xmr_usd_rate must be > 0")
return float(usd) / float(xmr_usd_rate)
Nostr signing
Notes are signed with BIP340 Schnorr (x-only key) — the same scheme Nostr
uses everywhere. The signing is pure-Python secp256k1, so there’s no
third-party crypto dependency to audit. The bot publishes the call-to-action
note and the confirmation note over a websocket to relays.
Security properties
- Non-custodial. The spend key never leaves the recipient wallet. The bot
only reads with the view key — a read-only statement, not a signing key. - No explorer. Verification runs against a public node you choose, via your
own wallet-rpc. No IP/address leak to a block-explorer service. - No full node required. A lightweight view-only wallet plus a remote node
is enough to confirm incoming payments. - Honest about limits. A view key proves incoming amounts to your
address. It does not prove the tipper’s identity, and it can’t initiate
refunds. For a tip flow that’s exactly the trust boundary you want.
Running it
The reference implementation is MIT licensed, standard-library-only for
networking and signing, with monero as the single third-party dependency
(used only for address validation). It publishes the CTA, listens for tip
intents, replies with a monero: URI, and verifies incoming payments
view-key-only. Bundle and demo are linked below.
Why this matters
A privacy coin deserves privacy-preserving infrastructure. A tip bot that holds
keys is just a tiny, buggy exchange. The view-key verifier pattern removes
that trust entirely — and it’s simple enough to audit in an afternoon.
Tip jar — XMR, no KYC, no account:
45bovHGLsAgihEWXgjoTwuBaUq1LogHCjeBYBN3xfN4J7gspatfQtb2WsyfsqX4dqMWUPXAEYFKV88zf9BpDFMM3HjnA7MC
Reference implementation (MIT): monero-nostr-tipbot
— bundle https://x0.at/drPE.zip · demo https://x0.at/fFhH.html
Also on Telegraph: https://telegra.ph/Anatomy-of-a-non-custodial-Monero-tip-bot-on-Nostr-08-29
Write a comment