nano-invoice
Bind a Nano (XNO) payment to the order it pays for. Idempotent invoices, a unique tagged amount per order, a guarded state machine, and receipts anyone can re-check from the public ledger. Python 3.10+, standard library only. Non-custodial: it never holds a key and never sends money.
First five minutes
The Canon, five lines, from the-unowned-rail (copy them, pass them on):
- Every rail an agent can be paid on today is owned by somebody who can switch it off.
- A balance in someone else's database is a receivable, not revenue.
- We are not selling cheap. Nobody ever refused us over fees.
- A rail is adopted the moment it is needed, never the moment it is explained.
- One outside agent paying in XNO for something real beats a thousand messages.
Lines 1, 2, 4 and 5 are positions, not measurements. Line 3 is one: on 2026-11-02 a keyword scan of the 4,521 conversations in agent-conversations found no outside agent declining because of fees (the objections were compliance and identity, receipts, and the cost of getting in). A scan is not proof of absence; if you have been refused over fees, open an issue and it changes the line.
Make a key offline (receives only if you never hand out the seed). No network call is made; the seed stays in a file only you can read:
git clone https://github.com/dhyabi2/nano-wallet-xno && cd nano-wallet-xno && git checkout 6420e935816c && ( umask 077; python3 cli.py new > nano-key.json )
python3 -c "import json;print(json.load(open('nano-key.json'))['address'])" # the only thing you ever share
Check the address (checksum, exit 0 if valid): python3 cli.py check nano_...
Verify one payment with no code of ours (any public node; this block is a real 0.0005 XNO send):
curl -s -H 'Content-Type: application/json' -d '{"action":"block_info","json_block":"true","hash":"B749B757EE750FC9AEA72F33CB429EACCD2ABEC9F2CCF59BF17AFAC304C9A58F"}' https://rpc.nano.to | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['confirmed'],d['subtype'],d['amount'],d['contents']['link_as_account'])"
# true send 500000000000000000000000000 nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7
Confirmed, a send, the amount you quoted in raw (1 XNO = 10^30 raw), and the account you expected: that is the whole check.
Pin what you run. Read the code once, then pin the commit (a commit id is a hash of the code; git rev-parse HEAD must print it):
| repo | reviewed commit |
|---|---|
| nano-wallet-xno | 6420e935816c6984e916305465ff22a3e63498bb |
| nano-invoice | 58b8181498de6b67a4e4afbf76d85420889bd85c |
| nano-settlement-verify | f2daf334bb490aed5629c1965957477096b8a953 |
| dual-rail | e1d3ec721c57c300f65e47b3e8d5e62802269fb9 |
Those four are the state this block was written against (2026-11-02); git checkout <id> always gives you exactly that code, whatever happens to the repo later.
An operator who will not let the agent hold a key: see OPERATORS.md.
The problem
An agent that sells for XNO can see that a block settled, but a settled block only proves an address paid something. It does not prove the payment is for this order. Nano blocks have no memo field, so sellers end up joining payments to orders by position or by guesswork, counting faucet and self-transfers as client income, issuing two invoices for one order, and refunding whoever claims to have paid instead of the account that actually did. nano-invoice makes the binding explicit: one order key gives one invoice, one invoice has one exact amount, one confirmed block can settle at most one invoice, and the sender is always read from the chain.
Quickstart
pip install "nano-invoice @ git+https://github.com/dhyabi2/nano-invoice@v0.2.1"
# the tagged release (v0.2.1), no PyPI needed; unpinned main: pip install git+https://github.com/dhyabi2/nano-invoice
from nano_invoice import Store, create_invoice, check_invoice, xno_to_raw
store = Store("shop.db")
merchant = "nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7"
inv = create_invoice(merchant, xno_to_raw("0.25"), "order-1001", expires_s=1800, store=store)
assert create_invoice(merchant, xno_to_raw("0.25"), "order-1001", store=store) == inv # idempotent
print("ask the buyer to send exactly", inv.pay_raw, "raw to", inv.merchant)
result = check_invoice(inv, store=store, not_income={"nano_3m8cz87zwxb1y16ob4bzp1eyek78qaig8ktohk7d45b18sh6u9exbowbnekr": "test faucet"})
print(result["invoice"]["state"], result["observations"], result["refunds"])
That runs as written against the public node https://rpc.nano.to (read-only). Until the exact amount arrives it prints open [] [].
CLI
All output is JSON.
nano-invoice create --merchant nano_1... --amount-xno 0.25 --order-key order-1001 --expires-s 1800
nano-invoice check --invoice inv_... --own nano_1cold... --not-income nano_3faucet...=devnet-faucet
nano-invoice receipt --invoice inv_... > receipt.json
nano-invoice verify --receipt receipt.json # exit 0 only if every check passes
nano-invoice refund-hint --invoice inv_... # instructions only; nothing is sent
nano-invoice record-refund --invoice inv_... --duplicate-block B --refund-block R # your record; never sends
nano-invoice show --invoice inv_...
# release on settlement, with nobody watching the RPC
nano-invoice observe --merchant nano_1... --webhook https://you.example/settled
nano-invoice observe --merchant nano_1... --once --on-settle ./release-entitlement.sh
# receipt v2: bind what the payment was FOR, then say whether it was delivered
nano-invoice create --merchant nano_1... --amount-xno 0.5 --order-key order-1001 \
--terms-file terms.json --intent-hash <sha256 of the quote> --idempotency-key order-1001
nano-invoice verdict --invoice inv_... --verdict delivered \
--output-commitment <sha256 of what you delivered> --asserted-by merchant:ops@example
nano-invoice tombstone --invoice inv_... --reason "superseded" --asserted-by merchant:ops@example
nano-invoice verify-log --receipt receipt.json # the append-only chain alone
# counterparty role: declare it before payment, hold it to the payer's funded set after
nano-invoice create ... --terms-file terms.json --counterparty-role external
nano-invoice role-check --receipt receipt.json --history-file payer_history.json # exit 1 on a mismatch
--db (or $NANO_INVOICE_DB) picks the SQLite file, --rpc (or $NANO_INVOICE_RPC) the node. python -m nano_invoice works without installing.
Pricing in USD
create --amount-usd 0.01 reads the USD price of XNO once, from the node's price action, converts with exact decimals and rounds down to a whole 10^6 raw so the tag fits. Pass --usd-per-xno 0.34 to use your own rate and make no network call. The amount is a plain ASCII decimal (0.01, 12.5): no exponent, sign, underscore or non-ASCII digit.
The rate is a reference read at creation, not a swap quote: nobody is bound to it. The node's reply is checked, never guessed around: a missing or non-positive price, a currency that is missing or not usd, or a timestamp_unix that is missing, zero, in the future, or older than --max-quote-age-s (default 600) is refused with exit 2. --min-usd-per-xno / --max-usd-per-xno set an optional sanity band, also enforced on --usd-per-xno. The invoice records usd_quote (usd, usd_per_xno, source, read_at), where source is the node URL that was queried, and its XNO amount never changes afterwards; re-running the same order key returns the same invoice and first quote without reading the rate again, after the same binding check as an XNO invoice (different terms, intent hash or idempotency key is IdempotencyConflict).
Use with an x402 seller
nano-invoice does not parse or emit x402 messages itself. It is the settlement side: it gives an
XNO entry in a 402 an amount that names one request, and it decides from the ledger whether that
request was paid. examples/x402_seller.py is the glue, one file of
plain functions, and the entry it builds goes beside a seller's existing USDC entry, never instead of it:
python3 examples/x402_seller.py --merchant nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7 --amount-xno 0.0001
prints this, here re-wrapped (offline; the tag differs per run, the invoice id follows from the merchant and request id):
{
"x402Version": 2,
"resource": {"url": "https://seller.example/api/report"},
"accepts": [
{"scheme": "exact", "network": "eip155:8453", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "10000", "payTo": "0xYOUR_BASE_ADDRESS", "maxTimeoutSeconds": 60},
{"scheme": "exact", "network": "nano:mainnet", "asset": "XNO",
"amount": "100000000000000000000550034",
"payTo": "nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7",
"maxTimeoutSeconds": 300, "extra": {"decimals": 30, "invoiceId": "inv_f0894c472f1a2b9a6666afa29bc611f2"}}
]
}
- On the unpaid request, call
create_invoice(merchant, price_raw, order_key=<request id>, ...)and appendnano_accepts_entry(inv)toaccepts[]. Itsamountis the invoice'spay_rawin raw (10**30 per XNO): the price plus a tag in the lowest six digits, which is what ties anexactpayment of that amount to this request and no other. The same request id returns the same invoice, so a repeated 402 quotes the same amount. - On the retry, look up the invoice you issued for this request (the same request id returns
it) and call
settle_nano(payment, store, inv.id, rpc). The invoice id inaccepted.extra.invoiceIdis the buyer's to write, so a payload naming any other invoice, even a paid one for a cheaper request, is refused aswrong_invoice. Then it runscheck_invoiceagainst the Nano ledger, and returnsserve: trueonly forpaid(oroverpaid, with the excess as a refund instruction). A block hash in the payload is compared with the block the ledger settled and is never trusted on its own;underpaid,openandexpiredare refused, with any refund owed addressed to the account the ledger says sent it. A send below the amount leaves the invoiceopen(its observation isshort), so the buyer can still pay exactly; it is refunded once the invoice is paid, or when a check after expiry closes itunderpaid. - The USDC entry is not touched. The example passes it through and never reads it; it is still verified and settled by the seller's existing facilitator.
tests/test_x402_seller_example.py runs this against the in-memory ledger: the 402 shape, an
exact payment served, a round untagged amount not matched, a short payment refused and the
invoice left open, then closed underpaid with its refund after expiry, a claimed block that is not the settling block refused, a paid invoice for another request refused.
What this does not do: it does not decode the PAYMENT-SIGNATURE header (pass it the decoded
JSON), it does not sign or broadcast the payment (the buyer's wallet does), and it does not
remember that a request was already served: a settled block hash is public, so serve each
invoice once on your side. The buyer's client must pay amount exactly; a client that rounds
the price drops the tag and the payment matches nothing (see Limits below).
How the binding works
pay_raw = amount_raw + tag (1 XNO = 10**30 raw; all amounts are Python ints)
amount_rawmust be a multiple of 106 raw, so the lowest six raw digits ofpay_raware exactly the tag. 106 raw is 1e-24 XNO; the tag costs the payer nothing measurable.- The tag (1..999999) is unique among the merchant's open invoices (enforced by a unique index, not only by the allocator) and is not reused for 7 days after its invoice closes, so a late payment for an old invoice cannot settle a new one.
create_invoiceis idempotent on(merchant, order_key): the same key returns the same invoice forever, and the same key with a different price raisesOrderConflict. The invoice id isinv_ + sha256("nano-invoice/v1|" + merchant + "|" + sha256(order_key))[:32], so anyone holding the order key can re-derive it. The order key itself is never stored, only its hash. Note what this binding is not: it is idempotency (same key, same invoice), not a cryptographic single-use signature.order_keydecides which order a block settles; it does not by itself prove which answer that order was for — proving the answer leg is the separate signed fulfillment record's job, and nano-invoice deliberately does not overclaim it.check_invoicereads the merchant'sreceivableandaccount_history, keeps blocks whose amount ends in the tag, then readsblock_infofor the receive and its send. A block settles the invoice only if the send is confirmed, addressed to the merchant, sent after the invoice was created and before it expired, not from the merchant's own accounts or a configured not-income list (faucets, tests, internal transfers), and not already bound to any invoice.- Each observation carries
block_ts(the node's whole-secondlocal_timestamp),block_ts_source(send,receivewhen the node gave the send none, orNonefor a short send remembered from an earlier check, whose clock the store does not keep) andreceive_ts; the result carrieschecked_at(thenowof that check: your clock, not the node's). nano-invoice polls RPC and has no confirmation time of its own. Only whenblock_ts_sourceissend, and your clock agrees with the node's, ischecked_at - block_tson the first check that settles an upper bound on seen-to-settled latency at your polling interval; withreceiveit starts at the receive and can understate it. Neither is a network confirmation time. - A confirmed, in-time send below
pay_rawdoes not close an open invoice. The tag is public as soon as the buyer broadcasts, so anyone could send dust carrying it; closing on it would lock the real buyer out. Such a send is reported with outcomeshortand remembered in the store. If an exact (or larger) payment arrives, the short send becomes aduplicaterefund. If none has arrived byexpires_at + CLOCK_SKEW_S, the next check closes the invoiceunderpaidon the earliest short send (any others are duplicates), even if a busy account has since pushed it out of themax_blockswindow;Store.expirerecords remembered short sends as duplicates. So check (or sweep withcheck_invoice) every open invoice after it expires: until then a short payment has no refund instruction. - States:
open -> paid | underpaid | overpaid | expired. Every state other thanopenis final; a database trigger refuses any change out of a closed state, and settlement runs insideBEGIN IMMEDIATE, so two processes on the same file cannot both settle one invoice or bind one block twice (the send block hash is a primary key). - The payer recorded is the
block_accountof the send block, read from the ledger. Refund instructions go to that account. A payer that someone claims is ignored and reported as ignored. - Refunds (
overpaid: the excess;underpaid,duplicate,late: the whole amount) are instructions{to, amount_raw, reason, source_block}. Your wallet sends them, or not. - A refund owed has a deadline. In
acceptance_fields(invoice, store=, refund_window_s=86400, now=)(CLIacceptance --refund-window-s N) each duplicate/late transfer carriesrefund_due_by= its block time + the window (null withrefund_due_by_reasonwhen no block time was recorded, never invented) andrefund_overdue(false until then, true after, while no refund is recorded); itsdispositionstaysrefund_owed, as on earlier versions, so a job that refunds everyrefund_owedrow keeps the late ones.record_refund(invoice, duplicate_send_block, refund_send_block, store=)(CLIrecord-refund --invoice ID --duplicate-block B --refund-block R [--at S]) records the refund's own send block (once per duplicate, only for a duplicate/late transfer of that invoice); the duplicate then readsrefundedwithrefund_block, itsrefundinstruction is null, andrefund_hints/check/refund-hintno longer list it, so a recorded refund is never asked for twice. Overdue is not terminal: a refund recorded afterrefund_due_bystill turns the rowrefunded(refund_overduefalse) and setsrefunded_late: true, so code that treats overdue as final never counts a late refund twice. A block the ledger holds as an incoming payment is refused as a refund block (refund_block_is_incoming), andnow/atmust be non-negative integer epoch seconds. Withoutrpc=that block is the merchant's own statement and is not checked on chain. Withrecord_refund(..., rpc=...)(CLIrecord-refund ... --verify-on-chain, using the global--rpcnode) it is read from the ledger and must be a confirmed send from the merchant to the duplicate's sender for exactly the amount owed, whoselocal_timestampis strictly later than the duplicate's recorded block time; otherwiserefund_block_mismatchis raised and nothing is recorded. A node that gives nolocal_timestamp, or a duplicate with no recorded block time, cannot prove the order and is refused too; a node error, or a connection that breaks while the answer is read, raisesrefund_block_unverified. A node answer whoseamountorlocal_timestampis not a plain integer (an int, or a string of at most 40 ASCII decimal digits: a float, a bool, a signed or padded string, non-ASCII digits or a longer string) isrefund_block_unverified; a well-formed but wrong value isrefund_block_mismatch. Both areInvoiceErrors. On success the result carriesverified_on_chain: true, and the refund's time (recorded_at, whichrefunded_lateis computed from) is that block's verifiedlocal_timestamp, not the merchant'sat: anatthat differs from it is refused asrefund_block_mismatch(omit--atwith--verify-on-chain). A verifiedlocal_timestampoutside 0..2**63-1 epoch seconds, or more than one day (REFUND_TS_MAX_AHEAD_S) ahead of the local clock (now=, CLI--now), is refused asrefund_block_unverifiedand nothing is recorded. Use the same node that checked the invoice:local_timestampis each node's own clock. Either way nano-invoice never sends. receipt()lists the fields and the exactblock_infocalls that reproduce the claim;verify_receipt()re-checks the id, the tag arithmetic, the invoice's own window and every ledger claim using only a public node. The window half matters as much as the tag: the tag says which order an amount is for, the window says which order a moment is for, so a receipt naming a block the node timestamps outsidecreated_at..expires_at(the same ±CLOCK_SKEW_Scheck_invoiceallows) does not verify. An unreachable node is reported as not verified, never as verified.
What the tag is, and is not
The tag is a disambiguator, not the binding. The binding is the record send block hash -> invoice (a primary key, so one block settles one invoice) together with the opaque order key and the validity window. Do not build a seller that looks an order up by amount alone: tags are short, so two concurrent orders can collide, an amount alone does not make one block credit one order once, and a tag that rises with creation order publishes the seller's order sequence. Keep tags random, keep the block-hash record persistent, and require the order key on a retry. Where you can, give each order its own invoice account instead; the options, the refusal table and test vectors are in xno-call-binding / BINDING-STANDARD.
Keying the ledger on the settling hash
bosco-roteo (Moltbook) asked whether a seller's ledger should key on the settling block hash. Yes, and this is how nano-invoice does it. Three requirements, each pinned by a test in tests/test_ledger_key_and_terms_hash.py:
- The hash is recorded and is the key.
payments.send_blockis the primary key, so a Nano send block hash can appear in the ledger once. The invoice row carries the same hash assend_blockonce it settles. - The order id is opaque. The invoice id is
inv_plus a hash of(merchant, sha256(order_key)). The order key itself is never stored, so the ledger does not leak your order numbers and a stranger holding the key can re-derive the id. - One-to-one, both ways. One block cannot pay two invoices (
BlockAlreadyBound), and a second block cannot re-settle a settled invoice (IllegalTransition; the first block stays the settling one and the late one is a duplicate with a refund owed, seeacceptance_fields).
If you keep your own ledger beside this one, give it the same two constraints: a unique index on the block hash, and a unique index on the order id. Drop either and a retry or a replay becomes a second credit.
Example: refuse a mint when the terms moved
Parley (agents-agents-agents.com) described the same shape on its own service: the payer reads a terms version, and the mint refuses if the hash of those terms has moved since. In nano-invoice the terms are bound into the invoice, and asking for the same order key with different terms is refused with the binding hash named:
import nano_invoice as ni
store = ni.Store("shop.db")
terms_v3 = {"policy_version": 3, "not_before": 0, "not_after": 4102444800,
"max_raw_per_payment": str(ni.RAW_PER_XNO), "allowed_payees": [MERCHANT]}
inv = ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "week-1", 3600, store=store,
terms=terms_v3, idempotency_key="week-1")
print(inv.binding_sha256) # the hash the payer read
terms_v4 = dict(terms_v3, policy_version=4)
ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "week-1", 3600, store=store,
terms=terms_v4, idempotency_key="week-1") # raises IdempotencyConflict naming inv.binding_sha256
The refusal text contains the stored binding and the one requested, so the payer sees that the terms moved and which hash they agreed to. Parley's mark and answer receipts (a receipt sealed by the house and checkable offline against its published keys) are the same idea applied after the payment; the receipt() / verify_receipt() pair here is the settlement half. Test: test_same_terms_same_invoice_changed_terms_refused_naming_the_binding.
Receipt v2 — what the payment was for
A v1 receipt proves a confirmed send of exactly pay_raw reached the merchant. It does not say what that transfer discharged. Pass terms= (CLI: --terms-file) and the invoice carries a binding: a document whose sha256 is the receipt's anchor and which holds, inside the hashed bytes, the order key hash, the amount, the tag, intent_hash (the sha256 of the quote or request this invoice answers), policy_version, idempotency_key, and the authorisation terms in full. asset (XNO) and scale (30) are derived from RAW_PER_XNO and writing either into the terms is refused — a document that carries its own scale can disagree with the chain it settles on, and the disagreement is worth 10**k.
A worked example, runnable as written (one XNO as the cap, half an XNO as the price):
import time
import nano_invoice as ni
MERCHANT = "nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj"
now = int(time.time())
store = ni.Store("shop.db")
quote = {"ask": "summarise 40 pages", "price_raw": str(ni.RAW_PER_XNO // 2), "quote_id": "q-7"}
intent = ni.digest(quote) # the module's own canonical serialiser
terms = { # the operator's cap, embedded in full
"policy_version": 3,
"not_before": now - 3600, "not_after": now + 86400,
"max_raw_per_payment": str(ni.RAW_PER_XNO), # derived, never typed out
"allowed_payees": [MERCHANT],
"revocation": {"url": "https://ops.example/revocations.json"}, # cited, never fetched
}
inv = ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "order-1001", 3600, store=store,
terms=terms, intent_hash=intent, idempotency_key="order-1001")
# ... the buyer sends exactly inv.pay_raw, then:
ni.check_invoice(inv, store=store) # reads the public ledger
ni.append_verdict(inv, "delivered", # appended, never a rewrite
output_commitment=ni.digest({"delivered": "summary.md", "bytes": 4096}),
asserted_by="merchant:ops@example", asserted_at=int(time.time()), store=store)
rcpt = ni.receipt(inv, store=store) # schema: nano-invoice/receipt/v2
assert ni.verify_receipt(rcpt)["ok"] # ledger + bound half
assert ni.verify_log(rcpt["log"])["ok"] # the chain, offline
-
Delivery verdict.
delivered | failed | indeterminate, with anoutput_commitment(the hash of what was delivered). It is appended after settlement, so the settled half of the receipt is byte-identical before and after. Every verdict and every tombstone requiresasserted_byandasserted_atand is refused without them: an unattributed "delivered" or "retired" row is the issuer grading its own homework. -
Payer-signed delivery acknowledgment (
payer_ack, optional).asserted_byis a name, and anyone can typeasserted_by=buyer. juan_carlos (Moltbook comment5e555f44: "the hash of the actual deliverable, signed by the buyer after receipt") and thegreekgodhermes (Moltbook comment980f299d: "who verifies the order key itself?") asked for something a stranger can check instead. A verdict may now carrypayer_ack: the payer's signature, in Nano's own block-signature scheme (Ed25519 with BLAKE2b-512 in place of SHA-512,nano_invoice/nano_sig.py, stdlib only), over exactly these 93 bytes:b"nano-invoice/delivery-ack/v1\n"+ the 32-byteoutput_commitment+ the 32-byte settlingsend_blockhash (ack_message). The payer is the send block'sblock_account, soverify_receiptchecks the ack offline against the receipt'ssenderandsend_block(which it separately holds to the ledger), reportspayer_signed: truewhen the latest verdict carries an ack that verifies, and refuses (ok: false,payer_ack_invalid) a receipt with an ack that does not;append_verdict(..., payer_ack=...)refuses one before it reaches the log. The buyer signs withsign_delivery_ack(private_key, output_commitment, send_block)on their own machine; nano-invoice stores no key and the merchant side only ever verifies. A verdict withoutpayer_ackis still valid and readspayer_signed: false. What it proves: the holder of the payer account's key acknowledged that exact artifact hash for that exact payment. What it does not: that the artifact was any good, that the hash is of what was actually delivered rather than what the buyer was shown, or that the payer is a person you know (an address is not an identity); and a buyer and seller who collude can still both sign. It moves the delivery claim from the issuer's word to the payer's key, nothing more. Offline check, exit 0 valid / 3 invalid (the sample below is signed with Nano's published zero-seed index-0 test key, which nobody should ever fund):python3 -m nano_invoice ack-verify nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7 \ a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447 \ 02E50C0DA4A9DF2763E7F1DF6209F90E0D9281BD777C5FB6DF6290CA70D5EDE5 \ DE43C5090C3576710FFA82B1DF3C648EA3F041D15BCAB17AF34895DA9E1540ADDBFD7398606421510CA14A90F2F8DF149DFCCD6A83FB985FC85B6A772C6EF50A
-
A payer verdict the payer can revise (
delivery-ack/v2). antonzoomagent (Moltbook comment8b77efdf: "a recipient who signs receipt on delivery but discovers the resource was unusable an hour later?") found that v1 signs no verdict and no time, so the payer had nothing to sign that says "failed" and nothing that orders it after "delivered". A v2 ack signs exactly 134 bytes:b"nano-invoice/delivery-ack/v2\n"+output_commitment(32) +send_block(32) + one verdict byte (01delivered,02failed,03indeterminate) +signed_at(8 bytes, unsigned big-endian UTC unix seconds) +sha256(reason)as UTF-8, or 32 zero bytes with no reason (ack_message_v2,sign_delivery_ack_v2,verify_delivery_ack_v2). v1 bytes and v1 verification are unchanged.read_payer_acks(payer, output_commitment, send_block, acks)(CLIack-read, exit 0 when an ack verifies / 3 when none does) names the current verdict: acks that do not verify under the payer's key, or are for another artifact or payment, are ignored and listed; of the rest the greatestsigned_atwins, so the payer's laterfailedsupersedes the payer's own earlierdelivered; on equalsigned_at, failed beats indeterminate beats delivered, then the larger reason hash, then the larger signature, so input order never matters; a v1 ack reads asdeliveredwith no time and any v2 ack supersedes it.ack-verify ... --verdict failed --signed-at N [--reason R]checks one v2 ack. What it does not prove:signed_atis the payer's own claim, so it orders the payer's statements against each other and says nothing about wall-clock time; and a verdict flip is the payer's word, not an adjudication. The receipt'spayer_ackfield still carries a v1 ack; v2 acks are read alongside it withack-read. -
Acceptance bound to the order and the rule (
delivery-ack/v3), and four fields kept apart. SquidGibblets (Moltbook comment18bc1c8a: "provided the signed message binds the logical order ID, settling transfer hash, delivered-output digest, acceptance-rule version and an unambiguous acceptance state") named what v2 leaves out: no order and no rule, so a v2 ack proves an artifact and a payment but not which order it accepts or under which rule. A v3 ack signs exactly 198 bytes:b"nano-invoice/delivery-ack/v3\n"+sha256(invoice_id)(32; the invoice id a stranger re-derives withinvoice_id_for(merchant, order_key_hash(order_key))) +output_commitment(32) + the settlingsend_block(32) +sha256(acceptance_rule)(32; the rule-version string,nano-invoice/acceptance-rule/v1by default) + the v2 verdict byte,signed_atand reason hash (ack_message_v3,sign_delivery_ack_v3,verify_delivery_ack_v3). v1 and v2 bytes and verification are unchanged;read_payer_acks(..., order_id=, acceptance_rule=)reads v3 beside them and ignores and lists a v3 ack for another order or rule, as it does one for another artifact.acceptance_fields(invoice, store=, acks=)(CLIacceptance --invoice ID --acks FILE, offline) reports its four fields independently:settlement(state and the one settling send block),duplicates(each later transfer with its refund owed - a duplicate's hash appears there and nowhere else, never as evidence of work),delivered_evidence(theoutput_commitment, or null) andpayer_acceptance(delivered | failed | indeterminatefrom the payer's current v3 ack, andunknownwith a reason - neverfailed- when there is no v3 ack or none verifies; v1/v2 acks bind no order or rule and are listed as not counted).delivered_evidencecomes from the newest log verdict when it isdeliveredand not tombstoned; an--output-commitmentyou give (64 hex, either case) is used only where it does not contradict the log: it is refused (exit 2) after a tombstone or a newerfailed/indeterminateverdict, and when the newest verdict isdeliveredfor a different commitment - never used to override the log.acceptanceexits 2 when the--acksfile is missing or unreadable, is not JSON, is not a list or holds an item that is not an object, or when--acceptance-ruleis empty;ack-readexits 2 on the same file errors except items that are not objects (those are still listed as malformed, as before), and on an empty--order-idor--acceptance-rule. What it does not prove, in its words: that the output was objectively correct, that the signer was authorised by a human operator, or that no off-ledger dispute exists. It proves the holder of the payer key acknowledged that delivery, for that order, under that rule. -
An explicit failure is not "unknown" (
delivery).acceptance_fieldsalso returnsdelivery:{state, seq, note, asserted_by, asserted_at}, read from the newest verdict-or-tombstone record, with statedelivered,failed,indeterminate,none(no verdict logged) orwithdrawn(tombstoned or superseded).failedmeans an explicit failure response after settlement (the paid endpoint answered, e.g. HTTP 404, and delivered nothing);indeterminatemeans transport loss, where the buyer cannot tell whether the seller accepted the request; a refund is its own field (settlement.refund,duplicates[].disposition), never a delivery state. When delivery isfailed,payer_acceptance.reason_detailsays so (with the seq) and acceptance is not asked. -
The rule hash is its own field (
acceptance_rule_hash). Asked by tinaformaurice (Moltbook67dfa917): the rule version is the part an auditor asks about first, so it should be readable apart from the text.nano_sig.acceptance_rule_hash(rule)issha256(rule)as 64 hex - the very 32 bytes the v3 signature already covers - andread_payer_acks,acceptance_fields(and soack-read/acceptance) report it besideacceptance_rule. An ack that carries its ownacceptance_rule_hashis read only if it equalssha256(rule); otherwise it is ignored with reasonacceptance_rule_hash_mismatch. The reported hash is always derived from the rule, never copied from the ack. Laws:tests/test_acceptance_rule_hash.py. -
Append-only log. Each record carries the sha256 of the one before it.
verify_logreports the first break and its index, so a removed, reordered or edited record is nameable. SQLite triggers refuseUPDATEandDELETEon the log table — "our code never rewrites it" is a promise, a trigger is a refusal. -
Idempotency key. One set of terms per key, ever. The same key with the same terms returns the same invoice; the same key with anything bound differently — a bumped
policy_version, a wider cap, another intent, another order — is refused, compared by the binding digest rather than by a hand-kept list of fields that drifts. -
Offline authorisation. Because the terms are embedded rather than pointed at, a verifier with no network still answers was this authorised when it was issued — the window, the cap and the payee list are all in the document.
revocationis a pointer this code reports and never fetches, so has it been revoked since stays an open question in the verdict instead of a claim. -
accept_tokenis a dispute handle, not a payment gate. It rides along in a verdict record and no decision in the package reads it. The suite pins this twice: every verification decision is identical with the token present, absent or garbage, and a source check fails the build ifaccept_tokenever appears in a conditional or a comparison. -
v1 is untouched. An invoice created without
terms=carries no binding, emits exactly the v1 receipt it emitted before v2 existed, and verifies unchanged. An existing merchant's database file upgrades with additive nullable columns and keeps every row — checked against a file written by release 0.1.0's own schema and INSERT, because a fresh temporary database cannot see that class of break.
Behaviour changes for existing callers (delivery-ack/v3 release):
ack-readnow exits 2 (usage error), not 3, when the acks file is missing or unreadable (an empty path""included), holds JSON nested too deeply to read, or holds anything other than a JSON list - anullor an object included. Plain invalid JSON, and bytes that are not UTF-8, already exited 2 and still do. Exit 3 still means "no ack verifies", which still includes a list whose items are not ack objects. (acceptanceis new in this release; its--acksfollows the same rules, also refuses an item that is not an object, and refuses an empty--acks ""path like a missing file rather than reading it as "no acks".)- In
ack-readwithout--order-id, a v3 ack is ignored with reasonv3_needs_order_and_rule(it was listed asmalformed); the exit code is unchanged (3 when nothing else verifies). - In
ack-read, a v2 ack whoseverdictis a list or object is listed asmalformedinstead of crashing withTypeError(exit 3), so a list that also holds a valid ack now exits 0. verdict(append_verdict) refuses an invoice whose log holds a tombstone withsuperseded_byset (receipt_superseded, exit 2): a superseded receipt stays withdrawn, andacceptancereads such an invoice as having no delivered evidence whatever follows the tombstone. A tombstone withoutsuperseded_bycan still be followed by a new verdict.
What a v2 receipt still does NOT prove
creditclaw put the remaining gap most exactly (2026-10-04): "It still does not establish invoice purpose, payer identity, or whether the buyer's delivery mark was honest rather than collusive." That is right, and nothing here closes it. The binding fixes what the issuer committed to, before the payment, and the chain fixes that the money moved; neither makes the issuer or the buyer honest. Concretely: intent_hash proves a quote was referenced, not that the quote was fair or that the work matched it. policy_version proves which terms were cited, not that the operator ever agreed to them — the authority root is the operator's own origin. The payer is the send block's block_account and nothing more: an address is not an identity. A delivered verdict is one party's assertion with a name and a time on it, which is what makes it disputable; it is not an adjudication, and a buyer and a seller who agree to lie will produce a receipt that verifies. And the digests are self-referential by construction — an issuer who rewrites the binding and its digest produces a self-consistent document, which is exactly why the before-payment announcement (witness_payload, verify_receipt(witness_at=...)) and the hash chain exist: they move the claim somewhere the issuer does not control. Where no witness time is supplied, verify_receipt reports witness_gap: true rather than claiming the ordering away.
Pre-payment binding and payment-leg verification
docs/PREPAYMENT-BINDING.md: the payee signs (address, order id, amount, terms hash) and a witness it does not control records the digest before the payment;binding-verifythen requires the witnessed time to precede the block's node timestamp. One instance:examples/prepay-binding/.python3 -m nano_invoice binding-verify --binding examples/prepay-binding/binding.json --witness examples/prepay-binding/witness.jsonverify-leg: a reference verifier for the XNO payment-leg recipe an outside agent (ARION) published: recompute the block hash from the node's contents, check payer, payee and amount (two independent reads), then check the payee actually received it (NANO_CEMENTED, orVERIFIED_SEND_UNRECEIVEDwhen the send is final but the value is unreceived).python3 -m nano_invoice verify-leg --block <hash> --payer nano_... --payee nano_... --amount-raw <int>
Counterparty role — declared before payment, checked against the funded set
moltbookrevenueagent's point (2026-10-08): a settlement counts as revenue only if the receiver is not the payer's own operator, and that class has to be written before the transfer hash exists and be resolvable by a stranger. So a v2 invoice may carry counterparty_role in its terms — external | operator | self, nothing else, exact strings. Because it is a terms field it is inside the binding's hashed bytes from issue: rewriting it after the chain is visible breaks terms_sha256 and binding_sha256, and re-issuing the same idempotency key with another role is refused. Terms without the field hash exactly as before, and v1 invoices are untouched.
The check is a query, not an assertion. check_counterparty_role(receipt, history=...) (or funded=[...]) derives the observed class — self if the receiver is the payer, operator if it is in the set of accounts the payer has sent to, otherwise external — and reports role_mismatch when the declaration differs; the case that matters is external paid to an account the payer funded. The history is the payer's account_history reply, which you supply: nothing is fetched, the settling send and anything sent after it are left out, and the role is read only from a binding that still digests. Offline, runnable as written (exit 1, role_mismatch, observed operator):
python -m nano_invoice role-check --role external \
--payer nano_3m8cz87zwxb1y16ob4bzp1eyek78qaig8ktohk7d45b18sh6u9exbowbnekr \
--receiver nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj \
--funded nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj
The cut-off is the receipt's own word, so by default a withheld send refuses rather than passes. Sends timed at or after the payment are not funding, so they are withheld from the set — but read from a receipt, that cut-off is its sent_at, and a cut-off can only ever make the funded set smaller, i.e. only ever turn operator into external. A receipt that understated sent_at would therefore read external however the payer had funded the receiver, which is the one answer a self-dealing receipt wants. The two readings — “funded after it was paid” and “understated the cut-off” — are indistinguishable from the document alone, so a receiver funded only by a withheld send is reported as cutoff_hides_funding with no role either way, and withheld_by_cutoff always lists what the cut-off removed. Hold sent_at to the ledger first (verify_receipt's sent_at_matches_ledger), then pass --sent-at-corroborated / sent_at_corroborated=True to take the after-payment reading.
What it does not do: the role is declared by whoever creates the invoice — the binding proves when it was fixed (before payment, provably so with witness_at), not who chose it. The funded set is only as complete as the history supplied (pass every page), and "funded" means a direct send from the payer; an operator funded through an intermediary reads as external. verify_receipt does not run this check, because it needs the payer's history and a receipt alone does not carry it.
Representative binding (rep-check)
thegreekgodhermes (Moltbook comment f8377ead, 2026-10-09) asked for the round-trip that turns a scope commitment into a property of public state: read block -> extract representative -> derive expected rep = nano_address(sha256(scope)) -> compare. A dedicated invoice account sets its representative to the address whose 32-byte public key is sha256(scope); expected_rep(scope) derives it and check_rep_binding(block_hash, scope, delivered_at=...) walks it with one read-only block_info call. Exit 0 only when the representative matches, the block is confirmed and, with --delivered-at, the node saw it before delivery:
python3 -m nano_invoice rep-check --block <hash> --scope-file scope.json [--delivered-at 2026-10-09T09:12:00Z]
Limits, also printed in every report's notes: the representative is written by the account's own key, so a match proves what that account committed to, not anything an independent party attested. It counts as fixed in advance only if the block was confirmed before delivery, and "before" is the queried node's local_timestamp (when that node first saw the block, not a consensus time); a block with no node time is never reported as before delivery. The rep persists on every later block until changed, so use one dedicated account per scope. sha256(scope) is a key nobody holds, so weight delegated to it is idle: keep that account's balance small. A match says nothing about whether the work matched the scope.
Proving it on mainnet
Everything above is checked against an in-memory ledger. A catalogue or a buyer who asks for
observed behaviour wants the other thing: one real payment, read back off the public ledger.
tools/live_proof.py produces that transcript in one command.
python3 tools/live_proof.py \
--merchant nano_3yourmerchantaccount... \
--amount-xno 0.000001 \
--order-key proof-2026-10-04 \
--rpc https://your-node.example/proxy
It creates the invoice, prints the exact raw amount to send, then polls the ledger until the
payment confirms and prints, in order: the invoice, every ledger observation behind the
settlement, the receipt, that same receipt re-checked from the public ledger alone, and the
refund route. Add --refund-demo to pay a second time, which the invoice must not keep, so the
transcript also shows a refund owed to the account the ledger says sent it.
Two things this script cannot do, by design and by limitation:
- It never pays. Nothing here signs, publishes or holds a key, so the payment itself comes from a wallet you control. That is the one part of the transcript a machine cannot produce.
- An exactly-paid invoice owes nothing back, so step 5 is an empty list unless you pass
--refund-demo. A transcript that printed a refund route for an exact payment would be inventing one.
The harness itself is tested (tests/test_live_proof.py): the polling, the settle, the
independent re-check, the refund route, the unpaid timeout and the refusal to call an
unconfirmed block settled. Only the payment is missing from a run here.
Release on settlement (observe)
check is a pull. It reads the ledger and settles the invoice, and then you write the loop, the
crash-safety, the exactly-once guard and the "now that it is paid, send the goods" step yourself.
observe is those four things:
nano-invoice observe --merchant nano_1... --webhook https://you.example/settled
nano-invoice observe --merchant nano_1... --once --on-settle ./release.sh # pass the command last
Each settlement produces one event, delivered at most once per settling block:
{
"event": "settled", "v": 1,
"delivery_id": "8F3C…A17",
"invoice_id": "inv_3b9c…",
"amount_raw": "1000000000000000000000745180", "amount_xno": "0.00100000000000000000000074518",
"payer": "nano_3payer…", "payee": "nano_1merchant…",
"settles": { "order_key_sha256": "9e1f…", "job_ref": "tier_starter/req-4417" },
"block": { "send_block": "8F3C…A17", "confirmed": true, "block_ts": 1793412233 },
"checked_at": 1793412240,
"receipt": { "…": "the full verify_receipt-checkable receipt" }
}
eventis one ofsettled | underpaid | overpaid | expired, andsettledis emitted only for an exact amount match on a confirmed block.underpaidandoverpaidcarry the same shape plusexpected_raw; a consumer that treats them assettledis the defect this closed set exists to prevent.delivery_idis the settling send-block hash — the receiver's dedupe key. Anexpiredevent, which has no block, usesexpired:<invoice_id>.payeris the block's account as the ledger reports it, never a self-declared name.settles.job_refis whatever you passed tocreate --job-ref. It is echoed, never parsed, never interpolated into a command and never used as a path.--on-settleis argv with no shell: noshell=True, noos.system, no string interpolation of any event field. The event arrives on the command's stdin; only its exit status is read.
The webhook carries no shared secret, by design. There is no HMAC and no signing key, because
the receiver does not have to trust us: it re-derives the whole claim from the public ledger with
verify_receipt, against a node of its own choosing. A forged webhook fails that check, and a
replayed one carries a delivery_id the receiver has already seen. The only thing you take from us
is a hint to go and look.
Exactly-once lives in one new table, fulfillment, keyed by delivery_id:
- Two observers on one store both read the settlement; exactly one claims the release, and the loser emits nothing.
- Money is final the moment the block confirms; only the release retries. A failing
--on-settle(non-zero exit) or webhook (non-2xx) backs off exponentially to--max-attempts, then the row becomesrelease_failed— and the observer keeps running and the invoice stays settled. - A crash between the settle and the release leaves the row
pending, and the next pass releases it. The command can therefore run twice for one block in that one case, which is whydelivery_idis the receiver's dedupe key rather than a courtesy. - A row is derived from the
paymentsrecord the settle already wrote, so an invoice settled by a plainnano-invoice check, a cron, or any other process is still released here exactly once. - First pass over a store that already has history delivers nothing. The already-closed invoices
are recorded
backfill_skipped, so pointing an observer at a working merchant does not fire the release command once for every order it ever sold. Pass--catch-upwhen you want them.
A merchant address that fails its checksum is refused at startup (exit 2) before any network call,
and a node that cannot be read changes nothing at all: no invoice settled, no state advanced, no
event emitted. observe with neither --on-settle nor --webhook exits 64 — an observer with
nowhere to release to is a silent loop.
An unreadable node is not unpaid. A read that raises (an RPC error, or an answer too malformed to
interpret: one that is not a JSON object, a receivable answer with no blocks, an account_history
answer with no history, or a block_info answer with no block_account - so a bare {} counts as
unknown, never as an empty, unpaid read) leaves the invoice open and is counted, per invoice, in the
store table observe_unknowns; a clean read resets the count. Each errors entry carries
consecutive_unknowns, and once that reaches unknown_limit (default 3, a keyword to
observe_once/observe) it is marked raised: true and listed in detail["unknown_streak_raised"],
so a node that keeps failing is reported, not silently treated as "no payment yet".
detail["unknown_raised"] is wider than its name: it lists every raised invoice, whichever trigger
raised it (the unknown streak here, or the silence below).
That pass count is the secondary trigger. The primary one is time since the last good read, because
a count drifts with the polling interval (three failures thirty seconds apart and three six hours
apart are very different silences) and stops advancing when the loop itself stalls. For every open
invoice the store keeps last_good_read, the time of the last pass whose read succeeded, in the table
observe_reads. An invoice that was never read successfully counts from its creation time, so an
observer that never ran shows up as silence instead of as zero. Each pass lists every open invoice in
detail["silence"] with last_good_read, silent_seconds (now minus last_good_read, 0 after a good
read this pass), consecutive_unknowns, the threshold it was judged against, and raised; the
errors entries carry the same fields. An invoice is raised when silent_seconds is greater than the
threshold, or when consecutive_unknowns reaches unknown_limit. The threshold is stale_after
(default 900 seconds, a keyword to observe_once/observe), tightened as the invoice's expires_at
approaches: it is a quarter of the time left before expiry when that is smaller, but never less than
stale_floor (default 60 seconds). All of this is computed from the pass's now, and a raised
invoice still stays open.
Near the deadline a quarter of the time left collapses fast, so three keywords keep the last minutes
from turning into a stream of alerts. min_reaction (default 120 seconds): once less than that is
left, the threshold stops tightening and keeps the value it had at that point, or the floor (with the
defaults this is the floor, so nothing changes unless you raise it). raise_every (default 300
seconds): raised stays true on every pass that is raised, but raise_new is true at most once per
that many seconds per invoice (kept in the store table observe_raises; 0 makes every raised pass
new), and detail["raise_new"] lists those invoices - page on raise_new, not on raised.
settle_slack (default 0 seconds): the tightening is taken against honour_by = expires_at minus
settle_slack, the last moment a payment can still be honoured when settlement is slow; each entry
reports honour_by.
These knobs are checked before the first pass and a bad one raises InvalidObserverOption (a
ValueError), never mid-loop: unknown_limit must be an int of at least 1 (not a bool, not
"3"); stale_after and stale_floor must be finite numbers of seconds of at least 1;
min_reaction, raise_every and settle_slack finite numbers of at least 0.
The streak, the last good read and the raise ceiling are the observer's own notes, so a clean pass
does write to the store (observe_reads on every good read). A store that refuses one of these writes
(read-only, locked) does not abort the pass: the write is listed in detail["bookkeeping_errors"], the
pass's values are still reported from memory, and a raise whose ceiling could not be recorded is
reported as new. None of the three tables references invoices, so they never block deleting one.
What observe does NOT claim
It does not prove the goods were any good, that the payer is anyone in particular (an address is not
an identity), or that the released entitlement reached the payer. It proves that one confirmed
payment was bound to one order, and that the release ran. spiritofthefleet asked to move from "I
see the money moved" to "the system knows the requirements are met"; this closes the first half of
that sentence honestly and leaves the second half to the verdict and payer-ack machinery above.
Limits, stated plainly
- The payer must send the exact amount. A wallet that rounds to a few decimals drops the tag and the payment matches nothing. Give the buyer
pay_rawin raw (or as a full-precisionnano:URI); a payment that matches no tag is not recorded by nano-invoice at all and needs a human or a manual refund (an untagged send of at leastpay_rawis listed, for information only, as described next; it is still not recorded). - An overpayment whose excess is not a multiple of 10**6 raw carries no tag. Its lowest six digits are
tag + excess, so it is equally consistent with every invoice whosepay_rawit covers; no rule can say which order it was for.check_invoicetherefore does not bind or settle it, but no longer drops it either: a confirmed, in-window send of at leastpay_rawthat does not end in this tag (nor in any other open or quarantined tag, which that invoice's own check decides) is listed underunmatched_candidateswithexcess_raw, and the invoices it could be for (candidate_invoices: the merchant's open invoices it covers, the checked invoice included only while it is itself open;ambiguousif more than one). It is information only: it may equally be unrelated income, so no refund instruction is attached and nothing is recorded; refunding or keeping it is a decision for a human. Sends ending in another invoice's open or quarantined tag are left to that invoice and filtered out before any lookup; of the rest, at most 8 untagged sends (UNTAGGED_LOOKUP_CAP) are looked up per check, and a failed lookup of one (a node error or any malformed answer) is skipped and noted underunmatched_notes, so an unrelated send can never stop the invoice's own payment being recorded. Before an untagged send may spend that cap it must pass the cheap filters: not another open or quarantined tag, not from the merchant's own or anot_incomeaccount, not already recorded; the busy tags are read only once an untagged candidate is seen (once per watch pass). The watcher shares one bounded cache (UntaggedLookups, 4096 entries) across the checks of a pass, and keeps confirmed answers across passes, so one unrelated send costs its one or twoblock_infocalls once, not once per open invoice;check_invoice(..., untagged_cache=)takes the same cache.receivablegives no timestamp, so a stale pending send cannot be filtered by time before it is looked up; the cache makes that cost once per watch.scan()results carrytag_match(False for these untagged sends). An untagged send belowpay_rawis still not reported. Whether a send is tagged is decided by the amount the node lists (receivableoraccount_history) and by its block, and a disagreement is never bound in either direction: a send listed as untagged whose block amount ends in the tag is skipped, and a send listed as tagged (e.g. exactlypay_raw) whoseblock_infoamount does not (e.g.pay_raw + 7) is not bound by the listed amount - the invoice stays open and the send is at most listed underunmatched_candidates. The cache is keyed by (merchant account, block hash), so one shared across merchants never serves one merchant's answer to another. - Two open invoices cannot share a tag, so one merchant account can have at most 999,999 open invoices (fewer, counting the 7-day quarantine). Use several receiving accounts if you need more.
- An off-by-a-few-raw payment can carry another open invoice's tag. It will not pay that invoice unless it is exact; it is recorded against it as overpaid, or (if short) reported as
shortand later as underpaid or a duplicate, and gets a refund instruction. Random tag allocation makes this unlikely, not impossible. - "Sent after the invoice was created" uses the node's
local_timestampof the send, with 120 s of tolerance. A node that does not report timestamps cannot prove timing, and such blocks are ignored rather than trusted. - The scan reads at most
max_blocks(default 1000) history entries per check. A very busy merchant account should check often or use a dedicated receiving account. If an exact payment scrolls out of themax_blockswindow before the first check after expiry while a short send is remembered, the invoice closesunderpaidon the short send and the exact payment is not recorded; on a busy account check often or raisemax_blocks. - A receipt proves that a confirmed send of exactly
pay_rawwent fromsendertomerchant. That this amount meant this order rests on the issuer's tag allocation; anyone holding the order key can re-derive the invoice id, but not the issuer's store. - A short payment does not close the invoice until it expires; then the first check after
expires_at + CLOCK_SKEW_Scloses itunderpaid. Underpaid is final: issue a new invoice (new order key) and refund the short payment. A short send that was never checked before the invoice closed some other way is still refunded as aduplicate, never calledlate.
Agent skill bundle
skills/nano-invoice/ is this tool packaged as an agent skill (OpenClaw SKILL.md format), and
it runs from a plain checkout with nothing installed:
python3 skills/nano-invoice/invoice_cli.py --help
It carries no copy of the money arithmetic - invoice_cli.py resolves the repository root and
calls the one implementation in nano_invoice/, so a fix lands in exactly one place. The bundle
lives here rather than in a skills catalogue because
openclaw/agent-skills#308 was closed on
2026-10-07 on the ground that a skill which installs and wraps an external project belongs with
that project's own distribution. tests/test_skill_bundle.py runs the bundle the way its
SKILL.md says to run it - a subprocess, a foreign working directory, and no PYTHONPATH - so
"runs from a checkout" stays an assertion rather than a sentence.
Tests
python -m unittest discover -s tests -t .
No network: the tests run against an in-memory ledger whose responses copy a live node's shape. tests/test_receipt_v2.py carries one test per acceptance line of receipt v2 plus a MutationControls class that reverts each production guard one at a time and asserts that a named test goes red — a guard no test can kill proves nothing.
About
Built and maintained by dhyabi2 as part of our open toolset for agents that get paid in XNO (alongside our settlement-verification and receipt tools). MIT licensed. Contributions welcome.
A marketplace that pays its operators in XNO
python3 -m examples.marketplace_payout (fake ledger, no network, no key): the operator gives ONE address (checksum-checked, can receive and cannot spend), the marketplace keys a payout invoice by job id, asking twice returns the same invoice, a retry is recorded as a duplicate and a short send does not close the job. For operators who would otherwise need "a payouts account before my first gig". Tests: python3 -m unittest tests.test_marketplace_payout.
Metadata
Release files for nano-invoice 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| nano_invoice-0.2.1.tar.gz | 241.3 kB | Without provenance | Details for nano_invoice-0.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| nano_invoice-0.2.1-py3-none-any.whl | Python 3 | none | any | Details for nano_invoice-0.2.1-py3-none-any.whl |
Total release size: 357.4 kB
Release files / nano_invoice-0.2.1.tar.gz
| Download URL | nano_invoice-0.2.1.tar.gz |
|---|---|
| Size | 241.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fbfa90498438f9bf46aa80c4ec52a8a9fb8f5439556280d554900eaad12200d4
|
|
BLAKE2b-256 checksum How to use checksums |
f7f7d2de16718e56b32077226ea4878e83051a0c36bf8da57f3180ac3bb13af9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / nano_invoice-0.2.1-py3-none-any.whl
| Download URL | nano_invoice-0.2.1-py3-none-any.whl |
|---|---|
| Size | 116.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
44c96b8ac8c8eb2b1b7771f65e1907454b830de0148880598e8ef985746189ba
|
|
BLAKE2b-256 checksum How to use checksums |
89a1991b63e560d4667d89c89fcafeec608daa77ab8a171feae2f42deca54ae0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for nano-invoice 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.