nano-invoice 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nano_invoice-0.2.1/LICENSE +21 -0
- nano_invoice-0.2.1/PKG-INFO +534 -0
- nano_invoice-0.2.1/README.md +522 -0
- nano_invoice-0.2.1/block_choice.py +130 -0
- nano_invoice-0.2.1/nano_invoice/__init__.py +34 -0
- nano_invoice-0.2.1/nano_invoice/__main__.py +3 -0
- nano_invoice-0.2.1/nano_invoice/account.py +88 -0
- nano_invoice-0.2.1/nano_invoice/cli.py +476 -0
- nano_invoice-0.2.1/nano_invoice/core.py +2117 -0
- nano_invoice-0.2.1/nano_invoice/hedera_memo.py +131 -0
- nano_invoice-0.2.1/nano_invoice/nano_sig.py +486 -0
- nano_invoice-0.2.1/nano_invoice/observer.py +729 -0
- nano_invoice-0.2.1/nano_invoice/prepay_binding.py +211 -0
- nano_invoice-0.2.1/nano_invoice/price.py +192 -0
- nano_invoice-0.2.1/nano_invoice/receipt_v2.py +759 -0
- nano_invoice-0.2.1/nano_invoice/rep_binding.py +136 -0
- nano_invoice-0.2.1/nano_invoice/rpc.py +59 -0
- nano_invoice-0.2.1/nano_invoice/xno_receipt.py +160 -0
- nano_invoice-0.2.1/nano_invoice.egg-info/PKG-INFO +534 -0
- nano_invoice-0.2.1/nano_invoice.egg-info/SOURCES.txt +55 -0
- nano_invoice-0.2.1/nano_invoice.egg-info/dependency_links.txt +1 -0
- nano_invoice-0.2.1/nano_invoice.egg-info/entry_points.txt +2 -0
- nano_invoice-0.2.1/nano_invoice.egg-info/top_level.txt +2 -0
- nano_invoice-0.2.1/pyproject.toml +23 -0
- nano_invoice-0.2.1/setup.cfg +4 -0
- nano_invoice-0.2.1/tests/test_acceptance_rule_hash.py +107 -0
- nano_invoice-0.2.1/tests/test_acceptance_v3.py +497 -0
- nano_invoice-0.2.1/tests/test_block_choice.py +115 -0
- nano_invoice-0.2.1/tests/test_counterparty_role.py +354 -0
- nano_invoice-0.2.1/tests/test_delivery_state.py +90 -0
- nano_invoice-0.2.1/tests/test_hedera_memo.py +85 -0
- nano_invoice-0.2.1/tests/test_invoice.py +982 -0
- nano_invoice-0.2.1/tests/test_latency_fields.py +72 -0
- nano_invoice-0.2.1/tests/test_ledger_key_and_terms_hash.py +71 -0
- nano_invoice-0.2.1/tests/test_live_proof.py +192 -0
- nano_invoice-0.2.1/tests/test_marketplace_payout.py +39 -0
- nano_invoice-0.2.1/tests/test_observer.py +342 -0
- nano_invoice-0.2.1/tests/test_observer_options.py +218 -0
- nano_invoice-0.2.1/tests/test_payer_ack.py +251 -0
- nano_invoice-0.2.1/tests/test_payer_verdict.py +174 -0
- nano_invoice-0.2.1/tests/test_prepay_binding.py +203 -0
- nano_invoice-0.2.1/tests/test_raise_floor.py +165 -0
- nano_invoice-0.2.1/tests/test_receipt_v2.py +1161 -0
- nano_invoice-0.2.1/tests/test_refund_bound.py +277 -0
- nano_invoice-0.2.1/tests/test_refund_verified.py +463 -0
- nano_invoice-0.2.1/tests/test_rep_binding.py +181 -0
- nano_invoice-0.2.1/tests/test_replay_trail.py +38 -0
- nano_invoice-0.2.1/tests/test_retry_trace.py +22 -0
- nano_invoice-0.2.1/tests/test_silence.py +149 -0
- nano_invoice-0.2.1/tests/test_skill_bundle.py +175 -0
- nano_invoice-0.2.1/tests/test_unknown_streak.py +79 -0
- nano_invoice-0.2.1/tests/test_untagged_cost.py +164 -0
- nano_invoice-0.2.1/tests/test_untagged_overpay.py +315 -0
- nano_invoice-0.2.1/tests/test_usd_price.py +517 -0
- nano_invoice-0.2.1/tests/test_version.py +17 -0
- nano_invoice-0.2.1/tests/test_x402_seller_example.py +117 -0
- nano_invoice-0.2.1/tests/test_xno_receipt.py +144 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dhyabi2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nano-invoice
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Bind a Nano (XNO) payment to the order it pays for: idempotent invoices, unique tagged amounts, ledger-verified receipts. Non-custodial, stdlib only.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/dhyabi2/nano-invoice
|
|
7
|
+
Keywords: nano,xno,invoice,agent-payments,x402
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# nano-invoice
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
## First five minutes
|
|
18
|
+
|
|
19
|
+
The Canon, five lines, from [the-unowned-rail](https://github.com/dhyabi2/the-unowned-rail) (copy them, pass them on):
|
|
20
|
+
|
|
21
|
+
1. Every rail an agent can be paid on today is owned by somebody who can switch it off.
|
|
22
|
+
2. A balance in someone else's database is a receivable, not revenue.
|
|
23
|
+
3. We are not selling cheap. Nobody ever refused us over fees.
|
|
24
|
+
4. A rail is adopted the moment it is needed, never the moment it is explained.
|
|
25
|
+
5. One outside agent paying in XNO for something real beats a thousand messages.
|
|
26
|
+
|
|
27
|
+
Lines 1, 2, 4 and 5 are positions, not measurements. Line 3 is one: on 2026-11-02 a keyword scan of the
|
|
28
|
+
4,521 conversations in [agent-conversations](https://github.com/dhyabi2/agent-conversations) found no outside
|
|
29
|
+
agent declining because of fees (the objections were compliance and identity, receipts, and the cost of getting in).
|
|
30
|
+
A scan is not proof of absence; if you have been refused over fees, open an issue and it changes the line.
|
|
31
|
+
|
|
32
|
+
**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:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
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 )
|
|
36
|
+
python3 -c "import json;print(json.load(open('nano-key.json'))['address'])" # the only thing you ever share
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Check the address** (checksum, exit 0 if valid): `python3 cli.py check nano_...`
|
|
40
|
+
|
|
41
|
+
**Verify one payment with no code of ours** (any public node; this block is a real 0.0005 XNO send):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
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'])"
|
|
45
|
+
# true send 500000000000000000000000000 nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Confirmed, a send, the amount you quoted in raw (1 XNO = 10^30 raw), and the account you expected: that is the whole check.
|
|
49
|
+
|
|
50
|
+
**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):
|
|
51
|
+
|
|
52
|
+
| repo | reviewed commit |
|
|
53
|
+
|---|---|
|
|
54
|
+
| nano-wallet-xno | `6420e935816c6984e916305465ff22a3e63498bb` |
|
|
55
|
+
| nano-invoice | `58b8181498de6b67a4e4afbf76d85420889bd85c` |
|
|
56
|
+
| nano-settlement-verify | `f2daf334bb490aed5629c1965957477096b8a953` |
|
|
57
|
+
| dual-rail | `e1d3ec721c57c300f65e47b3e8d5e62802269fb9` |
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
An operator who will not let the agent hold a key: see [OPERATORS.md](https://github.com/dhyabi2/the-unowned-rail/blob/main/OPERATORS.md).
|
|
61
|
+
|
|
62
|
+
## The problem
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install "nano-invoice @ git+https://github.com/dhyabi2/nano-invoice@v0.2.1"
|
|
70
|
+
# the tagged release (v0.2.1), no PyPI needed; unpinned main: pip install git+https://github.com/dhyabi2/nano-invoice
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from nano_invoice import Store, create_invoice, check_invoice, xno_to_raw
|
|
75
|
+
|
|
76
|
+
store = Store("shop.db")
|
|
77
|
+
merchant = "nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7"
|
|
78
|
+
inv = create_invoice(merchant, xno_to_raw("0.25"), "order-1001", expires_s=1800, store=store)
|
|
79
|
+
assert create_invoice(merchant, xno_to_raw("0.25"), "order-1001", store=store) == inv # idempotent
|
|
80
|
+
print("ask the buyer to send exactly", inv.pay_raw, "raw to", inv.merchant)
|
|
81
|
+
result = check_invoice(inv, store=store, not_income={"nano_3m8cz87zwxb1y16ob4bzp1eyek78qaig8ktohk7d45b18sh6u9exbowbnekr": "test faucet"})
|
|
82
|
+
print(result["invoice"]["state"], result["observations"], result["refunds"])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That runs as written against the public node `https://rpc.nano.to` (read-only). Until the exact amount arrives it prints `open [] []`.
|
|
86
|
+
|
|
87
|
+
## CLI
|
|
88
|
+
|
|
89
|
+
All output is JSON.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
nano-invoice create --merchant nano_1... --amount-xno 0.25 --order-key order-1001 --expires-s 1800
|
|
93
|
+
nano-invoice check --invoice inv_... --own nano_1cold... --not-income nano_3faucet...=devnet-faucet
|
|
94
|
+
nano-invoice receipt --invoice inv_... > receipt.json
|
|
95
|
+
nano-invoice verify --receipt receipt.json # exit 0 only if every check passes
|
|
96
|
+
nano-invoice refund-hint --invoice inv_... # instructions only; nothing is sent
|
|
97
|
+
nano-invoice record-refund --invoice inv_... --duplicate-block B --refund-block R # your record; never sends
|
|
98
|
+
nano-invoice show --invoice inv_...
|
|
99
|
+
|
|
100
|
+
# release on settlement, with nobody watching the RPC
|
|
101
|
+
nano-invoice observe --merchant nano_1... --webhook https://you.example/settled
|
|
102
|
+
nano-invoice observe --merchant nano_1... --once --on-settle ./release-entitlement.sh
|
|
103
|
+
|
|
104
|
+
# receipt v2: bind what the payment was FOR, then say whether it was delivered
|
|
105
|
+
nano-invoice create --merchant nano_1... --amount-xno 0.5 --order-key order-1001 \
|
|
106
|
+
--terms-file terms.json --intent-hash <sha256 of the quote> --idempotency-key order-1001
|
|
107
|
+
nano-invoice verdict --invoice inv_... --verdict delivered \
|
|
108
|
+
--output-commitment <sha256 of what you delivered> --asserted-by merchant:ops@example
|
|
109
|
+
nano-invoice tombstone --invoice inv_... --reason "superseded" --asserted-by merchant:ops@example
|
|
110
|
+
nano-invoice verify-log --receipt receipt.json # the append-only chain alone
|
|
111
|
+
|
|
112
|
+
# counterparty role: declare it before payment, hold it to the payer's funded set after
|
|
113
|
+
nano-invoice create ... --terms-file terms.json --counterparty-role external
|
|
114
|
+
nano-invoice role-check --receipt receipt.json --history-file payer_history.json # exit 1 on a mismatch
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`--db` (or `$NANO_INVOICE_DB`) picks the SQLite file, `--rpc` (or `$NANO_INVOICE_RPC`) the node. `python -m nano_invoice` works without installing.
|
|
118
|
+
|
|
119
|
+
### Pricing in USD
|
|
120
|
+
|
|
121
|
+
`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.
|
|
122
|
+
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`).
|
|
123
|
+
|
|
124
|
+
## Use with an x402 seller
|
|
125
|
+
|
|
126
|
+
nano-invoice does not parse or emit x402 messages itself. It is the settlement side: it gives an
|
|
127
|
+
XNO entry in a 402 an amount that names one request, and it decides from the ledger whether that
|
|
128
|
+
request was paid. [`examples/x402_seller.py`](examples/x402_seller.py) is the glue, one file of
|
|
129
|
+
plain functions, and the entry it builds goes **beside** a seller's existing USDC entry, never instead of it:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python3 examples/x402_seller.py --merchant nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7 --amount-xno 0.0001
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
prints this, here re-wrapped (offline; the tag differs per run, the invoice id follows from the merchant and request id):
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"x402Version": 2,
|
|
140
|
+
"resource": {"url": "https://seller.example/api/report"},
|
|
141
|
+
"accepts": [
|
|
142
|
+
{"scheme": "exact", "network": "eip155:8453", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
143
|
+
"amount": "10000", "payTo": "0xYOUR_BASE_ADDRESS", "maxTimeoutSeconds": 60},
|
|
144
|
+
{"scheme": "exact", "network": "nano:mainnet", "asset": "XNO",
|
|
145
|
+
"amount": "100000000000000000000550034",
|
|
146
|
+
"payTo": "nano_1yo6c1t64ahfjdw1dxizmbbnpdmbrckwhw9phbg5pdkeubrizga4qhnjmnx7",
|
|
147
|
+
"maxTimeoutSeconds": 300, "extra": {"decimals": 30, "invoiceId": "inv_f0894c472f1a2b9a6666afa29bc611f2"}}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
1. **On the unpaid request**, call `create_invoice(merchant, price_raw, order_key=<request id>, ...)`
|
|
153
|
+
and append `nano_accepts_entry(inv)` to `accepts[]`. Its `amount` is the invoice's `pay_raw`
|
|
154
|
+
in raw (10\*\*30 per XNO): the price plus a tag in the lowest six digits, which is what ties an
|
|
155
|
+
`exact` payment of that amount to this request and no other. The same request id returns the
|
|
156
|
+
same invoice, so a repeated 402 quotes the same amount.
|
|
157
|
+
2. **On the retry**, look up the invoice you issued for this request (the same request id returns
|
|
158
|
+
it) and call `settle_nano(payment, store, inv.id, rpc)`. The invoice id in
|
|
159
|
+
`accepted.extra.invoiceId` is the buyer's to write, so a payload naming any other invoice, even
|
|
160
|
+
a paid one for a cheaper request, is refused as `wrong_invoice`. Then it runs `check_invoice` against the Nano
|
|
161
|
+
ledger, and returns `serve: true` only for `paid` (or `overpaid`, with the excess as a refund
|
|
162
|
+
instruction). A block hash in the payload is compared with the block the ledger settled and is
|
|
163
|
+
never trusted on its own; `underpaid`, `open` and `expired` are refused, with any refund owed
|
|
164
|
+
addressed to the account the ledger says sent it. A send below the amount leaves the invoice
|
|
165
|
+
`open` (its observation is `short`), so the buyer can still pay exactly; it is refunded once the
|
|
166
|
+
invoice is paid, or when a check after expiry closes it `underpaid`.
|
|
167
|
+
3. **The USDC entry is not touched.** The example passes it through and never reads it; it is
|
|
168
|
+
still verified and settled by the seller's existing facilitator.
|
|
169
|
+
|
|
170
|
+
`tests/test_x402_seller_example.py` runs this against the in-memory ledger: the 402 shape, an
|
|
171
|
+
exact payment served, a round untagged amount not matched, a short payment refused and the
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
What this does not do: it does not decode the `PAYMENT-SIGNATURE` header (pass it the decoded
|
|
175
|
+
JSON), it does not sign or broadcast the payment (the buyer's wallet does), and it does not
|
|
176
|
+
remember that a request was already served: a settled block hash is public, so serve each
|
|
177
|
+
invoice once on your side. The buyer's client must pay `amount` exactly; a client that rounds
|
|
178
|
+
the price drops the tag and the payment matches nothing (see **Limits** below).
|
|
179
|
+
|
|
180
|
+
## How the binding works
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
pay_raw = amount_raw + tag (1 XNO = 10**30 raw; all amounts are Python ints)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- `amount_raw` must be a multiple of 10**6 raw, so the lowest six raw digits of `pay_raw` are exactly the tag. 10**6 raw is 1e-24 XNO; the tag costs the payer nothing measurable.
|
|
187
|
+
- 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.
|
|
188
|
+
- `create_invoice` is idempotent on `(merchant, order_key)`: the same key returns the same invoice forever, and the same key with a different price raises `OrderConflict`. The invoice id is `inv_ + 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_key` decides *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.
|
|
189
|
+
- `check_invoice` reads the merchant's `receivable` and `account_history`, keeps blocks whose amount ends in the tag, then reads `block_info` for 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.
|
|
190
|
+
- Each observation carries `block_ts` (the node's whole-second `local_timestamp`), `block_ts_source` (`send`, `receive` when the node gave the send none, or `None` for a short send remembered from an earlier check, whose clock the store does not keep) and `receive_ts`; the result carries `checked_at` (the `now` of that check: your clock, not the node's). nano-invoice polls RPC and has no confirmation time of its own. Only when `block_ts_source` is `send`, and your clock agrees with the node's, is `checked_at - block_ts` on the first check that settles an upper bound on seen-to-settled latency at your polling interval; with `receive` it starts at the receive and can understate it. Neither is a network confirmation time.
|
|
191
|
+
- A confirmed, in-time send below `pay_raw` does **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 outcome `short` and remembered in the store. If an exact (or larger) payment arrives, the short send becomes a `duplicate` refund. If none has arrived by `expires_at + CLOCK_SKEW_S`, the next check closes the invoice `underpaid` on the earliest short send (any others are duplicates), even if a busy account has since pushed it out of the `max_blocks` window; `Store.expire` records remembered short sends as duplicates. So **check (or sweep with `check_invoice`) every open invoice after it expires**: until then a short payment has no refund instruction.
|
|
192
|
+
- States: `open -> paid | underpaid | overpaid | expired`. Every state other than `open` is final; a database trigger refuses any change out of a closed state, and settlement runs inside `BEGIN 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).
|
|
193
|
+
- The payer recorded is the `block_account` of the send block, read from the ledger. Refund instructions go to that account. A payer that someone *claims* is ignored and reported as ignored.
|
|
194
|
+
- Refunds (`overpaid`: the excess; `underpaid`, `duplicate`, `late`: the whole amount) are instructions `{to, amount_raw, reason, source_block}`. Your wallet sends them, or not.
|
|
195
|
+
- A refund owed has a deadline. In `acceptance_fields(invoice, store=, refund_window_s=86400, now=)` (CLI `acceptance --refund-window-s N`) each duplicate/late transfer carries `refund_due_by` = its block time + the window (null with `refund_due_by_reason` when no block time was recorded, never invented) and `refund_overdue` (false until then, true after, while no refund is recorded); its `disposition` stays `refund_owed`, as on earlier versions, so a job that refunds every `refund_owed` row keeps the late ones. `record_refund(invoice, duplicate_send_block, refund_send_block, store=)` (CLI `record-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 reads `refunded` with `refund_block`, its `refund` instruction is null, and `refund_hints` / `check` / `refund-hint` no longer list it, so a recorded refund is never asked for twice. Overdue is not terminal: a refund recorded after `refund_due_by` still turns the row `refunded` (`refund_overdue` false) and sets `refunded_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`), and `now` / `at` must be non-negative integer epoch seconds. Without `rpc=` that block is the merchant's own statement and is not checked on chain. With `record_refund(..., rpc=...)` (CLI `record-refund ... --verify-on-chain`, using the global `--rpc` node) 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, whose `local_timestamp` is strictly later than the duplicate's recorded block time; otherwise `refund_block_mismatch` is raised and nothing is recorded. A node that gives no `local_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, raises `refund_block_unverified`. A node answer whose `amount` or `local_timestamp` is 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) is `refund_block_unverified`; a well-formed but wrong value is `refund_block_mismatch`. Both are `InvoiceError`s. On success the result carries `verified_on_chain: true`, and the refund's time (`recorded_at`, which `refunded_late` is computed from) is that block's verified `local_timestamp`, not the merchant's `at`: an `at` that differs from it is refused as `refund_block_mismatch` (omit `--at` with `--verify-on-chain`). A verified `local_timestamp` outside 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 as `refund_block_unverified` and nothing is recorded. Use the same node that checked the invoice: `local_timestamp` is each node's own clock. Either way nano-invoice never sends.
|
|
196
|
+
- `receipt()` lists the fields and the exact `block_info` calls 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 outside `created_at..expires_at` (the same ±`CLOCK_SKEW_S` `check_invoice` allows) does not verify. An unreachable node is reported as not verified, never as verified.
|
|
197
|
+
|
|
198
|
+
### What the tag is, and is not
|
|
199
|
+
|
|
200
|
+
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](https://github.com/dhyabi2/xno-call-binding/blob/main/docs/BINDING-STANDARD.md).
|
|
201
|
+
|
|
202
|
+
### Keying the ledger on the settling hash
|
|
203
|
+
|
|
204
|
+
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`:
|
|
205
|
+
|
|
206
|
+
1. **The hash is recorded and is the key.** `payments.send_block` is the primary key, so a Nano send block hash can appear in the ledger once. The invoice row carries the same hash as `send_block` once it settles.
|
|
207
|
+
2. **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.
|
|
208
|
+
3. **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, see `acceptance_fields`).
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
### Example: refuse a mint when the terms moved
|
|
213
|
+
|
|
214
|
+
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:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
import nano_invoice as ni
|
|
218
|
+
store = ni.Store("shop.db")
|
|
219
|
+
terms_v3 = {"policy_version": 3, "not_before": 0, "not_after": 4102444800,
|
|
220
|
+
"max_raw_per_payment": str(ni.RAW_PER_XNO), "allowed_payees": [MERCHANT]}
|
|
221
|
+
inv = ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "week-1", 3600, store=store,
|
|
222
|
+
terms=terms_v3, idempotency_key="week-1")
|
|
223
|
+
print(inv.binding_sha256) # the hash the payer read
|
|
224
|
+
terms_v4 = dict(terms_v3, policy_version=4)
|
|
225
|
+
ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "week-1", 3600, store=store,
|
|
226
|
+
terms=terms_v4, idempotency_key="week-1") # raises IdempotencyConflict naming inv.binding_sha256
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
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`.
|
|
230
|
+
|
|
231
|
+
## Receipt v2 — what the payment was *for*
|
|
232
|
+
|
|
233
|
+
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.
|
|
234
|
+
|
|
235
|
+
A worked example, runnable as written (one XNO as the cap, half an XNO as the price):
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
import time
|
|
239
|
+
import nano_invoice as ni
|
|
240
|
+
|
|
241
|
+
MERCHANT = "nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj"
|
|
242
|
+
now = int(time.time())
|
|
243
|
+
store = ni.Store("shop.db")
|
|
244
|
+
quote = {"ask": "summarise 40 pages", "price_raw": str(ni.RAW_PER_XNO // 2), "quote_id": "q-7"}
|
|
245
|
+
intent = ni.digest(quote) # the module's own canonical serialiser
|
|
246
|
+
|
|
247
|
+
terms = { # the operator's cap, embedded in full
|
|
248
|
+
"policy_version": 3,
|
|
249
|
+
"not_before": now - 3600, "not_after": now + 86400,
|
|
250
|
+
"max_raw_per_payment": str(ni.RAW_PER_XNO), # derived, never typed out
|
|
251
|
+
"allowed_payees": [MERCHANT],
|
|
252
|
+
"revocation": {"url": "https://ops.example/revocations.json"}, # cited, never fetched
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
inv = ni.create_invoice(MERCHANT, ni.RAW_PER_XNO // 2, "order-1001", 3600, store=store,
|
|
256
|
+
terms=terms, intent_hash=intent, idempotency_key="order-1001")
|
|
257
|
+
# ... the buyer sends exactly inv.pay_raw, then:
|
|
258
|
+
ni.check_invoice(inv, store=store) # reads the public ledger
|
|
259
|
+
|
|
260
|
+
ni.append_verdict(inv, "delivered", # appended, never a rewrite
|
|
261
|
+
output_commitment=ni.digest({"delivered": "summary.md", "bytes": 4096}),
|
|
262
|
+
asserted_by="merchant:ops@example", asserted_at=int(time.time()), store=store)
|
|
263
|
+
|
|
264
|
+
rcpt = ni.receipt(inv, store=store) # schema: nano-invoice/receipt/v2
|
|
265
|
+
assert ni.verify_receipt(rcpt)["ok"] # ledger + bound half
|
|
266
|
+
assert ni.verify_log(rcpt["log"])["ok"] # the chain, offline
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- **Delivery verdict.** `delivered | failed | indeterminate`, with an `output_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 requires `asserted_by` and `asserted_at` and is refused without them: an unattributed "delivered" or "retired" row is the issuer grading its own homework.
|
|
270
|
+
- **Payer-signed delivery acknowledgment (`payer_ack`, optional).** `asserted_by` is a name, and anyone can type `asserted_by=buyer`. juan_carlos (Moltbook comment `5e555f44`: *"the hash of the actual deliverable, signed by the buyer after receipt"*) and thegreekgodhermes (Moltbook comment `980f299d`: *"who verifies the order key itself?"*) asked for something a stranger can check instead. A verdict may now carry `payer_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-byte `output_commitment` + the 32-byte settling `send_block` hash (`ack_message`). The payer is the send block's `block_account`, so `verify_receipt` checks the ack offline against the receipt's `sender` and `send_block` (which it separately holds to the ledger), reports `payer_signed: true` when 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 with `sign_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 without `payer_ack` is still valid and reads `payer_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):
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
python3 -m nano_invoice ack-verify nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7 \
|
|
274
|
+
a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447 \
|
|
275
|
+
02E50C0DA4A9DF2763E7F1DF6209F90E0D9281BD777C5FB6DF6290CA70D5EDE5 \
|
|
276
|
+
DE43C5090C3576710FFA82B1DF3C648EA3F041D15BCAB17AF34895DA9E1540ADDBFD7398606421510CA14A90F2F8DF149DFCCD6A83FB985FC85B6A772C6EF50A
|
|
277
|
+
```
|
|
278
|
+
- **A payer verdict the payer can revise (`delivery-ack/v2`).** antonzoomagent (Moltbook comment `8b77efdf`: *"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 (`01` delivered, `02` failed, `03` indeterminate) + `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)` (CLI `ack-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 greatest `signed_at` wins, so the payer's later `failed` supersedes the payer's own earlier `delivered`; on equal `signed_at`, failed beats indeterminate beats delivered, then the larger reason hash, then the larger signature, so input order never matters; a v1 ack reads as `delivered` with 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_at` is 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's `payer_ack` field still carries a v1 ack; v2 acks are read alongside it with `ack-read`.
|
|
279
|
+
- **Acceptance bound to the order and the rule (`delivery-ack/v3`), and four fields kept apart.** SquidGibblets (Moltbook comment `18bc1c8a`: *"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 with `invoice_id_for(merchant, order_key_hash(order_key))`) + `output_commitment` (32) + the settling `send_block` (32) + `sha256(acceptance_rule)` (32; the rule-version string, `nano-invoice/acceptance-rule/v1` by default) + the v2 verdict byte, `signed_at` and 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=)` (CLI `acceptance --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` (the `output_commitment`, or null) and `payer_acceptance` (`delivered | failed | indeterminate` from the payer's current v3 ack, and **`unknown`** with a reason - never `failed` - when there is no v3 ack or none verifies; v1/v2 acks bind no order or rule and are listed as not counted). `delivered_evidence` comes from the newest log verdict when it is `delivered` and not tombstoned; an `--output-commitment` you 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 newer `failed`/`indeterminate` verdict, and when the newest verdict is `delivered` for a different commitment - never used to override the log. `acceptance` exits 2 when the `--acks` file is missing or unreadable, is not JSON, is not a list or holds an item that is not an object, or when `--acceptance-rule` is empty; `ack-read` exits 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-id` or `--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.
|
|
280
|
+
- **An explicit failure is not "unknown" (`delivery`).** `acceptance_fields` also returns `delivery`: `{state, seq, note, asserted_by, asserted_at}`, read from the newest verdict-or-tombstone record, with state `delivered`, `failed`, `indeterminate`, `none` (no verdict logged) or `withdrawn` (tombstoned or superseded). `failed` means an explicit failure response after settlement (the paid endpoint answered, e.g. HTTP 404, and delivered nothing); `indeterminate` means 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 is `failed`, `payer_acceptance.reason_detail` says so (with the seq) and acceptance is not asked.
|
|
281
|
+
- **The rule hash is its own field (`acceptance_rule_hash`).** Asked by tinaformaurice (Moltbook `67dfa917`): 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)` is `sha256(rule)` as 64 hex - the very 32 bytes the v3 signature already covers - and `read_payer_acks`, `acceptance_fields` (and so `ack-read` / `acceptance`) report it beside `acceptance_rule`. An ack that carries its own `acceptance_rule_hash` is read only if it equals `sha256(rule)`; otherwise it is ignored with reason `acceptance_rule_hash_mismatch`. The reported hash is always derived from the rule, never copied from the ack. Laws: `tests/test_acceptance_rule_hash.py`.
|
|
282
|
+
- **Append-only log.** Each record carries the sha256 of the one before it. `verify_log` reports the **first** break and its index, so a removed, reordered or edited record is nameable. SQLite triggers refuse `UPDATE` and `DELETE` on the log table — "our code never rewrites it" is a promise, a trigger is a refusal.
|
|
283
|
+
- **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.
|
|
284
|
+
- **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. `revocation` is 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.
|
|
285
|
+
- **`accept_token` is 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 if `accept_token` ever appears in a conditional or a comparison.
|
|
286
|
+
- **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.
|
|
287
|
+
|
|
288
|
+
**Behaviour changes for existing callers** (delivery-ack/v3 release):
|
|
289
|
+
|
|
290
|
+
- `ack-read` now 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 - a `null` or 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. (`acceptance` is new in this release; its `--acks` follows 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".)
|
|
291
|
+
- In `ack-read` without `--order-id`, a v3 ack is ignored with reason `v3_needs_order_and_rule` (it was listed as `malformed`); the exit code is unchanged (3 when nothing else verifies).
|
|
292
|
+
- In `ack-read`, a v2 ack whose `verdict` is a list or object is listed as `malformed` instead of crashing with `TypeError` (exit 3), so a list that also holds a valid ack now exits 0.
|
|
293
|
+
- `verdict` (`append_verdict`) refuses an invoice whose log holds a tombstone with `superseded_by` set (`receipt_superseded`, exit 2): a superseded receipt stays withdrawn, and `acceptance` reads such an invoice as having no delivered evidence whatever follows the tombstone. A tombstone without `superseded_by` can still be followed by a new verdict.
|
|
294
|
+
|
|
295
|
+
### What a v2 receipt still does NOT prove
|
|
296
|
+
|
|
297
|
+
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.
|
|
298
|
+
|
|
299
|
+
## Pre-payment binding and payment-leg verification
|
|
300
|
+
|
|
301
|
+
- `docs/PREPAYMENT-BINDING.md`: the payee signs (address, order id, amount, terms hash) and a witness it does not
|
|
302
|
+
control records the digest **before** the payment; `binding-verify` then requires the witnessed time to precede the
|
|
303
|
+
block's node timestamp. One instance: `examples/prepay-binding/`.
|
|
304
|
+
`python3 -m nano_invoice binding-verify --binding examples/prepay-binding/binding.json --witness examples/prepay-binding/witness.json`
|
|
305
|
+
- `verify-leg`: a reference verifier for the XNO payment-leg recipe an outside agent (ARION) published: recompute the
|
|
306
|
+
block hash from the node's contents, check payer, payee and amount (two independent reads), then check the payee
|
|
307
|
+
actually received it (`NANO_CEMENTED`, or `VERIFIED_SEND_UNRECEIVED` when the send is final but the value is unreceived).
|
|
308
|
+
`python3 -m nano_invoice verify-leg --block <hash> --payer nano_... --payee nano_... --amount-raw <int>`
|
|
309
|
+
|
|
310
|
+
## Counterparty role — declared before payment, checked against the funded set
|
|
311
|
+
|
|
312
|
+
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.
|
|
313
|
+
|
|
314
|
+
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`):
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
python -m nano_invoice role-check --role external \
|
|
318
|
+
--payer nano_3m8cz87zwxb1y16ob4bzp1eyek78qaig8ktohk7d45b18sh6u9exbowbnekr \
|
|
319
|
+
--receiver nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj \
|
|
320
|
+
--funded nano_3un4xgn97mxejkoewydihe57ijgx3j83tp988zu9d4oujhdjc1d1k4jnkouj
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**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.
|
|
324
|
+
|
|
325
|
+
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.
|
|
326
|
+
|
|
327
|
+
## Representative binding (rep-check)
|
|
328
|
+
|
|
329
|
+
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:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
python3 -m nano_invoice rep-check --block <hash> --scope-file scope.json [--delivered-at 2026-10-09T09:12:00Z]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
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.
|
|
336
|
+
|
|
337
|
+
## Proving it on mainnet
|
|
338
|
+
|
|
339
|
+
Everything above is checked against an in-memory ledger. A catalogue or a buyer who asks for
|
|
340
|
+
*observed* behaviour wants the other thing: one real payment, read back off the public ledger.
|
|
341
|
+
`tools/live_proof.py` produces that transcript in one command.
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
python3 tools/live_proof.py \
|
|
345
|
+
--merchant nano_3yourmerchantaccount... \
|
|
346
|
+
--amount-xno 0.000001 \
|
|
347
|
+
--order-key proof-2026-10-04 \
|
|
348
|
+
--rpc https://your-node.example/proxy
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
It creates the invoice, prints the exact raw amount to send, then polls the ledger until the
|
|
352
|
+
payment confirms and prints, in order: the invoice, every ledger observation behind the
|
|
353
|
+
settlement, the receipt, that same receipt re-checked **from the public ledger alone**, and the
|
|
354
|
+
refund route. Add `--refund-demo` to pay a second time, which the invoice must not keep, so the
|
|
355
|
+
transcript also shows a refund owed to the account the ledger says sent it.
|
|
356
|
+
|
|
357
|
+
Two things this script cannot do, by design and by limitation:
|
|
358
|
+
|
|
359
|
+
- **It never pays.** Nothing here signs, publishes or holds a key, so the payment itself comes
|
|
360
|
+
from a wallet you control. That is the one part of the transcript a machine cannot produce.
|
|
361
|
+
- **An exactly-paid invoice owes nothing back**, so step 5 is an empty list unless you pass
|
|
362
|
+
`--refund-demo`. A transcript that printed a refund route for an exact payment would be
|
|
363
|
+
inventing one.
|
|
364
|
+
|
|
365
|
+
The harness itself is tested (`tests/test_live_proof.py`): the polling, the settle, the
|
|
366
|
+
independent re-check, the refund route, the unpaid timeout and the refusal to call an
|
|
367
|
+
unconfirmed block settled. Only the payment is missing from a run here.
|
|
368
|
+
|
|
369
|
+
## Release on settlement (`observe`)
|
|
370
|
+
|
|
371
|
+
`check` is a *pull*. It reads the ledger and settles the invoice, and then you write the loop, the
|
|
372
|
+
crash-safety, the exactly-once guard and the "now that it is paid, send the goods" step yourself.
|
|
373
|
+
`observe` is those four things:
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
nano-invoice observe --merchant nano_1... --webhook https://you.example/settled
|
|
377
|
+
nano-invoice observe --merchant nano_1... --once --on-settle ./release.sh # pass the command last
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Each settlement produces one event, delivered at most once per settling block:
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{
|
|
384
|
+
"event": "settled", "v": 1,
|
|
385
|
+
"delivery_id": "8F3C…A17",
|
|
386
|
+
"invoice_id": "inv_3b9c…",
|
|
387
|
+
"amount_raw": "1000000000000000000000745180", "amount_xno": "0.00100000000000000000000074518",
|
|
388
|
+
"payer": "nano_3payer…", "payee": "nano_1merchant…",
|
|
389
|
+
"settles": { "order_key_sha256": "9e1f…", "job_ref": "tier_starter/req-4417" },
|
|
390
|
+
"block": { "send_block": "8F3C…A17", "confirmed": true, "block_ts": 1793412233 },
|
|
391
|
+
"checked_at": 1793412240,
|
|
392
|
+
"receipt": { "…": "the full verify_receipt-checkable receipt" }
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- `event` is one of `settled | underpaid | overpaid | expired`, and **`settled` is emitted only for
|
|
397
|
+
an exact amount match on a confirmed block**. `underpaid` and `overpaid` carry the same shape plus
|
|
398
|
+
`expected_raw`; a consumer that treats them as `settled` is the defect this closed set exists to
|
|
399
|
+
prevent.
|
|
400
|
+
- `delivery_id` is the settling send-block hash — **the receiver's dedupe key**. An `expired` event,
|
|
401
|
+
which has no block, uses `expired:<invoice_id>`.
|
|
402
|
+
- `payer` is the block's account as the ledger reports it, never a self-declared name.
|
|
403
|
+
- `settles.job_ref` is whatever you passed to `create --job-ref`. It is echoed, never parsed, never
|
|
404
|
+
interpolated into a command and never used as a path.
|
|
405
|
+
- `--on-settle` is **argv with no shell**: no `shell=True`, no `os.system`, no string interpolation
|
|
406
|
+
of any event field. The event arrives on the command's stdin; only its exit status is read.
|
|
407
|
+
|
|
408
|
+
**The webhook carries no shared secret, by design.** There is no HMAC and no signing key, because
|
|
409
|
+
the receiver does not have to trust us: it re-derives the whole claim from the public ledger with
|
|
410
|
+
`verify_receipt`, against a node of its own choosing. A forged webhook fails that check, and a
|
|
411
|
+
replayed one carries a `delivery_id` the receiver has already seen. *The only thing you take from us
|
|
412
|
+
is a hint to go and look.*
|
|
413
|
+
|
|
414
|
+
Exactly-once lives in one new table, `fulfillment`, keyed by `delivery_id`:
|
|
415
|
+
|
|
416
|
+
- Two observers on one store both read the settlement; exactly one claims the release, and the loser
|
|
417
|
+
emits nothing.
|
|
418
|
+
- Money is final the moment the block confirms; only the *release* retries. A failing `--on-settle`
|
|
419
|
+
(non-zero exit) or webhook (non-2xx) backs off exponentially to `--max-attempts`, then the row
|
|
420
|
+
becomes `release_failed` — and **the observer keeps running and the invoice stays settled**.
|
|
421
|
+
- A crash between the settle and the release leaves the row `pending`, and the next pass releases it.
|
|
422
|
+
The command can therefore run twice for one block in that one case, which is why `delivery_id` is
|
|
423
|
+
the receiver's dedupe key rather than a courtesy.
|
|
424
|
+
- A row is derived from the `payments` record the settle already wrote, so an invoice settled by a
|
|
425
|
+
plain `nano-invoice check`, a cron, or any other process is still released here exactly once.
|
|
426
|
+
- **First pass over a store that already has history delivers nothing.** The already-closed invoices
|
|
427
|
+
are recorded `backfill_skipped`, so pointing an observer at a working merchant does not fire the
|
|
428
|
+
release command once for every order it ever sold. Pass `--catch-up` when you want them.
|
|
429
|
+
|
|
430
|
+
A merchant address that fails its checksum is refused at startup (exit 2) before any network call,
|
|
431
|
+
and a node that cannot be read changes nothing at all: no invoice settled, no state advanced, no
|
|
432
|
+
event emitted. `observe` with neither `--on-settle` nor `--webhook` exits 64 — an observer with
|
|
433
|
+
nowhere to release to is a silent loop.
|
|
434
|
+
|
|
435
|
+
**An unreadable node is not unpaid.** A read that raises (an RPC error, or an answer too malformed to
|
|
436
|
+
interpret: one that is not a JSON object, a `receivable` answer with no `blocks`, an `account_history`
|
|
437
|
+
answer with no `history`, or a `block_info` answer with no `block_account` - so a bare `{}` counts as
|
|
438
|
+
unknown, never as an empty, unpaid read) leaves the invoice open and is counted, per invoice, in the
|
|
439
|
+
store table `observe_unknowns`; a clean read resets the count. Each `errors` entry carries
|
|
440
|
+
`consecutive_unknowns`, and once that reaches `unknown_limit` (default 3, a keyword to
|
|
441
|
+
`observe_once`/`observe`) it is marked `raised: true` and listed in `detail["unknown_streak_raised"]`,
|
|
442
|
+
so a node that keeps failing is reported, not silently treated as "no payment yet".
|
|
443
|
+
`detail["unknown_raised"]` is wider than its name: it lists every raised invoice, whichever trigger
|
|
444
|
+
raised it (the unknown streak here, or the silence below).
|
|
445
|
+
|
|
446
|
+
That pass count is the secondary trigger. The primary one is time since the last good read, because
|
|
447
|
+
a count drifts with the polling interval (three failures thirty seconds apart and three six hours
|
|
448
|
+
apart are very different silences) and stops advancing when the loop itself stalls. For every open
|
|
449
|
+
invoice the store keeps `last_good_read`, the time of the last pass whose read succeeded, in the table
|
|
450
|
+
`observe_reads`. An invoice that was never read successfully counts from its creation time, so an
|
|
451
|
+
observer that never ran shows up as silence instead of as zero. Each pass lists every open invoice in
|
|
452
|
+
`detail["silence"]` with `last_good_read`, `silent_seconds` (now minus `last_good_read`, 0 after a good
|
|
453
|
+
read this pass), `consecutive_unknowns`, the threshold it was judged against, and `raised`; the
|
|
454
|
+
`errors` entries carry the same fields. An invoice is raised when `silent_seconds` is greater than the
|
|
455
|
+
threshold, or when `consecutive_unknowns` reaches `unknown_limit`. The threshold is `stale_after`
|
|
456
|
+
(default 900 seconds, a keyword to `observe_once`/`observe`), tightened as the invoice's `expires_at`
|
|
457
|
+
approaches: it is a quarter of the time left before expiry when that is smaller, but never less than
|
|
458
|
+
`stale_floor` (default 60 seconds). All of this is computed from the pass's `now`, and a raised
|
|
459
|
+
invoice still stays open.
|
|
460
|
+
|
|
461
|
+
Near the deadline a quarter of the time left collapses fast, so three keywords keep the last minutes
|
|
462
|
+
from turning into a stream of alerts. `min_reaction` (default 120 seconds): once less than that is
|
|
463
|
+
left, the threshold stops tightening and keeps the value it had at that point, or the floor (with the
|
|
464
|
+
defaults this is the floor, so nothing changes unless you raise it). `raise_every` (default 300
|
|
465
|
+
seconds): `raised` stays true on every pass that is raised, but `raise_new` is true at most once per
|
|
466
|
+
that many seconds per invoice (kept in the store table `observe_raises`; 0 makes every raised pass
|
|
467
|
+
new), and `detail["raise_new"]` lists those invoices - page on `raise_new`, not on `raised`.
|
|
468
|
+
`settle_slack` (default 0 seconds): the tightening is taken against `honour_by` = `expires_at` minus
|
|
469
|
+
`settle_slack`, the last moment a payment can still be honoured when settlement is slow; each entry
|
|
470
|
+
reports `honour_by`.
|
|
471
|
+
|
|
472
|
+
These knobs are checked before the first pass and a bad one raises `InvalidObserverOption` (a
|
|
473
|
+
`ValueError`), never mid-loop: `unknown_limit` must be an `int` of at least 1 (not a `bool`, not
|
|
474
|
+
`"3"`); `stale_after` and `stale_floor` must be finite numbers of seconds of at least 1;
|
|
475
|
+
`min_reaction`, `raise_every` and `settle_slack` finite numbers of at least 0.
|
|
476
|
+
|
|
477
|
+
The streak, the last good read and the raise ceiling are the observer's own notes, so a clean pass
|
|
478
|
+
does write to the store (`observe_reads` on every good read). A store that refuses one of these writes
|
|
479
|
+
(read-only, locked) does not abort the pass: the write is listed in `detail["bookkeeping_errors"]`, the
|
|
480
|
+
pass's values are still reported from memory, and a raise whose ceiling could not be recorded is
|
|
481
|
+
reported as new. None of the three tables references `invoices`, so they never block deleting one.
|
|
482
|
+
|
|
483
|
+
### What `observe` does NOT claim
|
|
484
|
+
|
|
485
|
+
It does not prove the goods were any good, that the payer is anyone in particular (an address is not
|
|
486
|
+
an identity), or that the released entitlement reached the payer. It proves that one confirmed
|
|
487
|
+
payment was bound to one order, and that the release ran. `spiritofthefleet` asked to move from *"I
|
|
488
|
+
see the money moved"* to *"the system knows the requirements are met"*; this closes the first half of
|
|
489
|
+
that sentence honestly and leaves the second half to the verdict and payer-ack machinery above.
|
|
490
|
+
|
|
491
|
+
## Limits, stated plainly
|
|
492
|
+
|
|
493
|
+
- **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_raw` in raw (or as a full-precision `nano:` 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 least `pay_raw` is *listed*, for information only, as described next; it is still not recorded).
|
|
494
|
+
- **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 whose `pay_raw` it covers; no rule can say which order it was for. `check_invoice` therefore does not bind or settle it, but no longer drops it either: a confirmed, in-window send of at least `pay_raw` that does not end in this tag (nor in any other open or quarantined tag, which that invoice's own check decides) is listed under `unmatched_candidates` with `excess_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; `ambiguous` if 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 under `unmatched_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 a `not_income` account, 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 two `block_info` calls once, not once per open invoice; `check_invoice(..., untagged_cache=)` takes the same cache. `receivable` gives 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 carry `tag_match` (False for these untagged sends). An untagged send *below* `pay_raw` is still not reported. Whether a send is tagged is decided by the amount the node *lists* (`receivable` or `account_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. exactly `pay_raw`) whose `block_info` amount 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 under `unmatched_candidates`. The cache is keyed by (merchant account, block hash), so one shared across merchants never serves one merchant's answer to another.
|
|
495
|
+
- **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.
|
|
496
|
+
- 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 `short` and later as underpaid or a duplicate, and gets a refund instruction. Random tag allocation makes this unlikely, not impossible.
|
|
497
|
+
- "Sent after the invoice was created" uses the node's `local_timestamp` of 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.
|
|
498
|
+
- 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 the `max_blocks` window before the first check after expiry while a short send is remembered, the invoice closes `underpaid` on the short send and the exact payment is not recorded; on a busy account check often or raise `max_blocks`.
|
|
499
|
+
- A receipt proves that a confirmed send of exactly `pay_raw` went from `sender` to `merchant`. 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.
|
|
500
|
+
- A short payment does not close the invoice until it expires; then the first check after `expires_at + CLOCK_SKEW_S` closes it `underpaid`. 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 a `duplicate`, never called `late`.
|
|
501
|
+
|
|
502
|
+
## Agent skill bundle
|
|
503
|
+
|
|
504
|
+
`skills/nano-invoice/` is this tool packaged as an agent skill (OpenClaw `SKILL.md` format), and
|
|
505
|
+
it runs from a plain checkout with nothing installed:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
python3 skills/nano-invoice/invoice_cli.py --help
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
It carries no copy of the money arithmetic - `invoice_cli.py` resolves the repository root and
|
|
512
|
+
calls the one implementation in `nano_invoice/`, so a fix lands in exactly one place. The bundle
|
|
513
|
+
lives here rather than in a skills catalogue because
|
|
514
|
+
[openclaw/agent-skills#308](https://github.com/openclaw/agent-skills/pull/308) was closed on
|
|
515
|
+
2026-10-07 on the ground that a skill which installs and wraps an external project belongs with
|
|
516
|
+
that project's own distribution. `tests/test_skill_bundle.py` runs the bundle the way its
|
|
517
|
+
SKILL.md says to run it - a subprocess, a foreign working directory, and no `PYTHONPATH` - so
|
|
518
|
+
"runs from a checkout" stays an assertion rather than a sentence.
|
|
519
|
+
|
|
520
|
+
## Tests
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
python -m unittest discover -s tests -t .
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
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.
|
|
527
|
+
|
|
528
|
+
## About
|
|
529
|
+
|
|
530
|
+
Built and maintained by [dhyabi2](https://github.com/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.
|
|
531
|
+
|
|
532
|
+
## A marketplace that pays its operators in XNO
|
|
533
|
+
|
|
534
|
+
`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`.
|