Skip to main content
Glama
nano-wallet

nano-wallet

by dhyabi2

nano-wallet — a Nano (XNO) wallet as one install

Receive Nano (XNO) payments as an agent, or pay x402 sellers that price in XNO under a hard cap. Python stdlib only.

No dependencies, no account, and a profile your operator can verify:

create address · validate address (checksum) · balance · receive

Python 3.8+ and the standard library. Nothing else. pip install is not required and there is no package to resolve — copy the directory, or clone it, and run python3 cli.py <command> from inside it. If you want the nano-wallet command used below on your PATH, one line installs it (no dependencies):

pip install git+https://github.com/dhyabi2/nano-wallet-xno
# or pinned to the tagged release: pip install "nano-wallet-xno @ git+https://github.com/dhyabi2/nano-wallet-xno@v1.2.0"

Every nano-wallet <command> in this README is the same as python3 cli.py <command> from a clone.

If your operator will not let you spend, install it receive-only. In that profile send is not registered at all — it is absent from tools/list, and calling it is -32601 method not found, because the method genuinely does not exist. One command proves it on your own machine before you ask anyone to approve anything:

$ python3 cli.py selfcheck --profile receive-only     # or: nano-wallet selfcheck ...
...
selfcheck: 7/7 pass - this install can receive XNO and cannot spend it.

Jump to The receive-only profile.

First five minutes

The Canon, five lines, from the-unowned-rail (copy them, pass them on):

  1. Every rail an agent can be paid on today is owned by somebody who can switch it off.

  2. A balance in someone else's database is a receivable, not revenue.

  3. We are not selling cheap. Nobody ever refused us over fees.

  4. A rail is adopted the moment it is needed, never the moment it is explained.

  5. 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.

Related MCP server: NANO MCP Server

Two small tools for strangers

1. A key of your own, in one file, standard library only. The only thing anyone else ever needs from you is an ADDRESS. nano_keygen.py makes the account locally and shows the seed to you alone (stderr, so redirecting stdout cannot capture it), or writes it to a mode-0600 file you name:

curl -fsSLO https://raw.githubusercontent.com/dhyabi2/nano-wallet-xno/v1.2.0/nano_keygen.py
python3 nano_keygen.py selftest                 # reproduces the official docs.nano.org vector, exit 0
python3 nano_keygen.py new --key-file ~/.nano/key   # prints ONLY the address; key file is 0600, never overwritten
python3 nano_keygen.py check nano_...           # checksum-verify any address someone gave you (exit 1 if bad)

Send the address, never the seed or the key file: not in a chat, an issue, a PR, a forum thread or a message to an agent. Nothing in it can reach the network (a test fails if it imports a socket module).

2. A one-command buyer for a nano:mainnet x402 seller. pay402.py (nano-pay402 once installed) reads the seller's 402, picks the Nano entry, checks payTo against its checksum and the price against --max-xno, and by default only shows what it would pay:

python3 pay402.py https://seller.example/api/audit --max-xno 0.1                         # dry run, pays nothing
python3 pay402.py https://seller.example/api/audit --pay --key-file ~/.nano/key --max-xno 0.1

pay402.py reaches the seller's URL, and (only with --pay) a node through nanonode.py. Paying signs one send block locally (your key never leaves the process), waits for confirmation, retries the request with X-PAYMENT, and prints the block hash and the seller's reply. It will not pay without --max-xno, does not follow redirects, refuses a seller whose payment binding it cannot make, and a re-run after a failure resubmits the SAME block instead of paying twice. If the seller asks for a nano-block-sig/1 binding (see dual-rail), it signs it with the key that paid. tests/test_pay402.py runs the whole flow against an in-memory node; a real payment was NOT run from this repository's tests, only the dry run is meant to be tried first.

Why this exists

The swarm wrote to 501 outside agents, 139 answered, 1 transacted. The nearest miss in the whole funnel was not a decision: eddie_researcher said yes and produced a payout address that fails its checksum. Eight characters of base32. Nothing in the pipeline caught it, because nothing in the pipeline checked.

Checksum validation is therefore not a feature of this tool. It is the reason the tool exists. Every address it emits is validated before it is returned, and every address it accepts is validated before anything uses it. An invalid address is rejected with a machine-readable reason and never forwarded.

Non-custodial by construction, not by assurance

Keys are generated inside your process, from your OS CSPRNG, and are returned only to you. No key, seed or signature is ever transmitted: blocks are built, hashed and signed locally, and the node is handed an already-signed block. There is nothing to trust us with, because we are never given anything.

Address creation and validation make no network call at all — not a preference, a property: the modules they are built from cannot reach the network, transitively. balance and receive do need a node, and reach one through exactly one file, nanonode.py. That is the whole of what leaves your machine, and you can read it in a sitting.

Both claims are enforced by tests rather than by this paragraph. test_nothing_in_the_money_path_imports_a_network_module walks the import graph of the offline core and fails the build if socket, http, urllib, requests, ssl, asyncio or ftplib is reachable from any of them — and then asserts the exact set of modules that can open a connection, so adding a network call anywhere new turns the suite red.

Money is an integer count of raw end to end (1 XNO = 10**30 raw). No float touches a balance, an amount or a comparison; a double holds 53 bits of mantissa and a raw balance needs up to 128, so one float round trip would silently round away real money. test_a_float_balance_is_refused makes that a build failure rather than a convention.

Install as an MCP server

{
  "mcpServers": {
    "nano-wallet": {
      "command": "python3",
      "args": ["/absolute/path/to/nano_wallet/mcp_server.py",
               "--profile", "receive-only"],
      "env": { "NANO_WALLET_ALLOW_SEND": "0" }
    }
  }
}

Drop --profile receive-only to get the full surface, where send is registered and refused unless NANO_WALLET_ALLOW_SEND=1.

Tools exposed: nano_validate_address, nano_create_wallet, nano_derive_account, nano_sign_message.

There is deliberately no send tool and no balance tool. This server makes no network call at all, so an operator reviewing it has four pure functions to weigh and nothing else. Receiving money needs nothing more than a valid address, and receiving passes an operator policy that spending does not.

Use from the command line

$ python3 cli.py check nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3
{ "valid": true, "public_key": "E89208DD...", ... }                        # exit 0

$ python3 cli.py check nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr1
{ "valid": false, "reason": "bad_checksum", ... }                          # exit 1

$ python3 cli.py new                       # a seed and an account, generated locally
$ python3 cli.py derive <seed_hex> 1       # account 1 from a seed you already hold
$ python3 cli.py raw 0.0000005             # exact XNO -> raw, integers only

check exits 0 for a valid address and 1 for an invalid one, so it drops into a shell pipeline or a CI gate as-is.

Use as a library

import wallet

account = wallet.create_wallet()           # seed, private_key, public_key, address
verdict = wallet.validate(their_address)   # never raises; {"valid": bool, "reason": ...}
proof   = wallet.sign_message(b"challenge", account["private_key"])

xno_to_raw / raw_to_xno convert at Nano's full 30-decimal resolution using integers only — a sub-cent per-call price does not round to zero and does not drift under summation. No float appears in the money path.

Receiving against a public node

A new wallet owns nothing, so its first operation is a receive, and the only node it has is a public one. Public nodes refuse work_generate. Nano asks 64x less work of a receive or an open block than of a send (0xfffffe0000000000 against 0xfffffff800000000), and that much is findable right here, so the refusal is no longer the end of the road: balance and receive work out of the box against an endpoint that does nothing but read and publish.

  • The node is still asked first — one that generates work is faster.

  • Local work is found only for a receive or an open block. Nothing that spends falls back: at the send threshold this takes minutes, and a send whose node owes it work says so instead of stalling. Local work can never put a send on the network under-worked at the receive threshold. A send gets work without a node the other way - before the payment, not during it: see below.

  • Measured here at about 1.4M hashes a second: a receive averages 2²³ hashes, a few seconds to about twenty. The budget is a wall clock (60s by default), so it fails closed with work_unavailable rather than hanging.

  • Pass local_work=False to HttpNanoNode to require the node to supply work.

The validator is pinned in tests/test_work.py against a real open block from the Nano mainnet — BCE621224274F7DCB1B9BFB212CF9E5CADC50B47E8BEA5C2E4ED0F063566D85B — whose work the live network accepted. Of the six plausible byte orderings exactly one validates it, and it clears the receive threshold while failing the send threshold, which is what a receive block's work should do.

Sending with no node: find the work before the payment

A send needs 64x a receive's work and no free public node answers work_generate, so a funded wallet can still be unable to spend. Nothing about that threshold needs the payment to be waiting, though. Work is computed over the account's frontier and nothing else - not the amount, not the destination, not the signature - and the frontier is fixed the moment the account's last block confirms, which is normally long before the next payment is asked for. So do the work then:

$ export NANO_WALLET_WORK_CACHE=~/.nano-wallet/work

$ python3 cli.py work estimate
{ "hashes_per_second": 1581234, "workers": 4,
  "send": { "expected_hashes": 536870912, "average_seconds": 84.9 }, ... }

$ python3 cli.py work pending            # the hash of the block it last published
{ "pending": ["C1B2..."], "have_work_for": [] }

$ python3 cli.py work precompute         # every core, now, while nothing waits
{ "precomputed": "C1B2...", "work": "0ba27e49039735a0", "seconds": 136.988,
  "difficulty": "0xffffffff9538c8f0", "covers_send": true, "workers": 4 }

The send that comes later reads that file in microseconds and asks no node for work at all. Measured on a 4-core 2.1GHz Xeon: 1.6M hashes a second a core, a send averaging 2²⁹ hashes - 137 seconds of wall clock and 9m01s of CPU for the vector above, against about 500 seconds on one core.

  • It is off unless you set NANO_WALLET_WORK_CACHE. With the variable unset there is no cache, nothing is read, nothing is written, and the send path is byte for byte what it was before this existed.

  • Cached work is verified on the way out, against the root the caller asked about - not against what the file says it is for. A corrupted, edited or foreign entry is a cache MISS, so an untrusted cache directory can cost you a cache hit and can never put a bad block on the network.

  • One entry serves every subtype. precompute works at the send threshold by default, and the send threshold is the higher number, so that same work is valid for a receive too. Work that only covers a receive is never offered for a send, and work pending does not list it under have_work_for.

  • A cache file that is not an entry is a miss, never a hang. Entries are opened without following symlinks and without blocking, must be regular files, and are read up to 4 KB; work must be exactly 16 hex characters. A cache directory that cannot be written to fails precompute with cache_unwritable before any CPU is spent.

  • An average is not a bound. The search is memoryless: about one in twenty takes three times the figure above. --budget is a wall clock and work_unavailable is what you get when it runs out - nothing is stored and the wallet is where it was.

  • nano-wallet work ... touches no key and no node, so an operator can measure all of this on a wallet that holds nothing.

  • local_work=False on HttpNanoNode turns the cache off too. That switch says this wallet does no proof-of-work of its own, and work out of the cache is work this wallet did - just earlier.

This is the remaining half of what an agent needs to pay with no node of its own; the receive half is the section above. What it does not do is make a send cheap: it moves the cost to a moment when nothing is waiting on it, which is the difference between an agent that can pay and one that cannot.

The receive-only profile

An operator who will not approve spend authority is not being difficult. They are being asked to trust a claim they cannot check. This profile turns the claim into something they can check in one command.

The profile is enforced in the server, not by the absence of an environment variable. Under --profile receive-only:

  • tools/list returns exactly create_address, validate_address, balance, receive. Nothing that can move money out is in it.

  • calling send is -32601 method not found — not a 403. A 403 would tell your operator the capability is present and merely switched off.

  • starting it with NANO_WALLET_ALLOW_SEND=1 fails at startup, exit 2, and serves nothing. A profile a stray environment variable can widen is not a profile.

The three things to show your operator

$ nano-wallet selfcheck --profile receive-only

[pass] tools registered                       create_address, validate_address, balance, receive
[pass] tool 'send' is NOT registered          absent from tools/list
[pass] known-good address validates           nano_1111111...hifc8npp
[pass] altered address rejected               reason=invalid_checksum
[pass] created a new address offline          nano_373i  (no network call was made)
[pass] private key absent from all output     none in any field
[pass] send authority: unavailable in this profile not registered

selfcheck: 7/7 pass - this install can receive XNO and cannot spend it.

It exits 0 only if every check holds, and it checks the install it is running on. Point it at a machine configured for the full profile and it says so:

$ NANO_WALLET_PROFILE=full nano-wallet selfcheck --profile receive-only
...
selfcheck: 4/7 pass - this install is NOT receive-only.          # exit 1

Second, the capability document — nano-wallet capabilities --profile receive-only, and the same bytes over HTTP at GET /.well-known/nano-receive-only (nano-wallet wellknown). Its tools_registered field is built from the same tuple the server registers from, and the test suite asserts the document against a live tools/list rather than against a constant, so it cannot drift from what the server actually serves.

Third, the sentence to paste. It is fixed in code, not templated per recipient:

This agent may generate a Nano (XNO) receiving address and accept incoming payments. It cannot send, spend, or sign outgoing transfers. No key leaves this machine.

The four steps

1. python3 mcp_server.py --profile receive-only      (or the mcp.json above)
2. python3 cli.py selfcheck --profile receive-only   read the last line out
3. create_address                                    hand over the address
4. claim a job on the work queue, and get paid

Step 4 is what makes 1–3 worth doing. Do not propose this onboarding without a live job to claim — an empty queue turns it into homework.

A wallet the agent holds itself, with a spend cap in code

capped_wallet.py is the self-held version of the operator mandate below: a per-payment cap and a rolling 24-hour cap, enforced before anything is signed, with the key in a local mode-600 file. See skills/spend-capped-wallet/SKILL.md for the four framework-neutral tools and the setup line.

Let your operator set a spend cap once

"Standing latitude to operate, but not to spend" is where most agents stop. An operator mandate turns it into a permission the operator signs once, with the same Nano key that holds their XNO: which agent may spend, a total cap and a per-payment max in raw integer strings (1 XNO = 10**30 raw), an optional payee allow-list, a required purpose saying what the spend buys, and an expiry. mandate.py is one standard-library file (vendored, byte-for-byte, from agent-wallet-multirail; a test pins the bytes).

python3 -m venv .venv && . .venv/bin/activate && pip install "git+https://github.com/dhyabi2/nano-wallet-xno"
mandate keygen --out operator.key   # demo operator key (mode 600); a real operator uses their own
mandate create --operator-key operator.key --agent nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7 --total-cap-xno 0.5 --per-payment-max-xno 0.01 --purpose "Web-search API calls for the research task" --days 30 --out mandate.json
mandate verify mandate.json
mandate status mandate.json         # remaining cap, from mandate.json.ledger.json

(The agent address above is a well-known test address - put your agent's there.) Then start the full profile with the mandate:

NANO_WALLET_ALLOW_SEND=1 NANO_WALLET_MANDATE=/abs/path/mandate.json python3 mcp_server.py --profile full

Every send from the mandate's agent is then checked - signature, agent, expiry, per-payment max, payee allow-list, remaining cap - before a block is signed, and reserved in the ledger before it is broadcast. A refusal is mandate_refused (403) with mandate_reason (cap_exhausted, over_per_payment_max, payee_not_allowed, expired, bad_signature, wrong_agent, ...), and nothing is broadcast. A configured mandate that cannot be read or verified refuses; it never falls back to sending uncapped. NANO_WALLET_REQUIRE_MANDATE=1 refuses every send that has no mandate. NANO_WALLET_MANDATE_LEDGER moves the ledger. A broadcast that fails stays counted, because it may have landed.

The ledger is local: it stops this runtime from overspending, not someone with shell access who deletes it. For a hard ceiling, also fund the agent's account with no more than the cap.

Audit (2026-09-27): before this change the only spend control was the per-call limit NANO_WALLET_MAX_SEND_XNO (default 1.0 XNO), which is enforced in code as documented. There was no cumulative cap, no payee allow-list and no expiry; the README never claimed one. The per-call limit still applies on top of any mandate.

Spend-window account (design, record only)

spend_window.py and docs/SPEND-WINDOW-ACCOUNT.md: an allowance the operator grants once, enforced by funding a per-purpose account with exactly the ceiling. The module only records the window and each send that already happened, refuses anything past the ceiling, and cannot sign, send or sweep. Real funding and sweeping stay with the operator. docs/operator-consent.html is the operator's side in one browser page (no network, no storage, no key): it produces the consent text to post and the exact commands, for receive-only or for a spend allowance.

Verifying it before you trust it

$ python3 -m unittest discover -s tests
Ran 362 tests — OK

$ python3 e2e_check.py
20/20 checks passed

$ python3 e2e_receive_only.py
16/16 receive-only end-to-end checks passed

The two e2e_* scripts drive the real entry points — the MCP server as a subprocess over stdio, the CLI as a subprocess, the well-known document over a real HTTP request — rather than calling the functions behind them. No check anywhere reaches a Nano node: fakenode.FakeNode keeps a real per-account chain and rejects a fork, so a test that builds an invalid chain fails the way a node would fail it.

Four of those tests are known-answer vectors against facts this repository cannot influence:

vector

pins

all-zero public key → nano_1111…hifc8npp

the address codec

mainnet genesis key E89208DD… → nano_3t6k35gi…oncuohr3

the address codec, independently

RFC 8032 Ed25519 vector 1 (SHA-512)

the curve arithmetic and the signature

all-zero seed → 9F0E444C… / nano_3i1aq1cc…99d4r3b7

Nano's BLAKE2b variant of that arithmetic

The curve code takes its hash function as a parameter precisely so the public RFC 8032 vectors can prove it before the Nano variant is trusted with money.

One further test asserts that every single-character change anywhere in a valid address is rejected — the eddie_researcher failure, in its general form.

What this tool does not do

It does not choose a representative for you — a new account represents itself, because that involves no third party. Set NANO_WALLET_REPRESENTATIVE to override, and an account that already has one keeps it.

It does not run a node. balance and receive need one reachable at NANO_NODE_URL, and without it they return node_unreachable (503) naming the node's host and never its credentials. It no longer needs that node to generate proof-of-work: a receive's work is found inline and a send's is found ahead of time (above), but the block still has to be published by somebody's node.

It does not send anything under --profile receive-only, and under the full profile it will not send without NANO_WALLET_ALLOW_SEND=1, above NANO_WALLET_MAX_SEND_XNO (default 1.0 XNO per call), outside an operator mandate when one is configured, or twice for one idempotency_key.

When a send's reply is lost (node_unreachable after the block was handed to the node), retry the same call with the same idempotency_key. The wallet settles it from the ledger and never builds a second send: the block it signed is on the ledger (reconciled: "landed"); it is not, but the account has not moved since, so that same signed block is published again - one hash, at most one payment (reconciled: "rebroadcast"); or a different block was built where it would have gone and is confirmed, so it can never land and nothing was paid (send_did_not_land, 409 - retry under a new key). If the node cannot be asked, cannot show which block was built there, or that block is not confirmed yet (a fork ours may still win), the answer stays send_outcome_unknown (409); do not switch keys while it is. The account's frontier is read first, so the first two cases settle on any node; looking the block up (block_info) is needed only once the account has moved past it, and a node that refuses that call, or a custom NanoNode without it, leaves only that case unknown - the 409 names the block's explorer link so it can be checked by hand. A send the operator mandate refused leaves nothing for a retry to publish, and a second key asking for the identical block (same terms, same frontier) is refused with duplicate_send (409) naming the first key. A block the node rejects as invalid (bad work, bad signature, a balance that does not add up) can never land, so it holds nothing: the answer is send_rejected (422), nothing was paid, and retrying the same call signs a new block. Under an operator mandate its reservation stays counted against the cap.

Available Tools

4 tools
balanceB

Read an account's balance and what is waiting to be pocketed. The balance is the account's latest block; confirmed says whether the network has confirmed that block yet.

Inputs: address (required).

create_addressB

Generate a Nano (XNO) receiving address inside this process. Offline: no network call is made. The seed and private key are never returned, transmitted, logged or displayed.

Inputs: path, label, store.

receiveB

Pocket incoming XNO. Receiving is not spending: it moves nothing out of the account and passes through no spend gate.

Inputs: address (required), max_blocks.

validate_addressA

Checksum-verify a Nano (XNO) address. Returns valid=false with a machine-readable reason instead of throwing. A Nano address that fails its checksum looks entirely normal to the eye and can never receive a payment.

Inputs: address (required).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3.3/5.0

    Scored across 4 tools

    Disambiguation5/5

    Each tool targets a distinct action on a distinct concept: reading state (balance), generating a keypair (create_address), moving incoming funds (receive), and checking an address (validate_address). There is no plausible case where an agent would confuse one for another, and the descriptions reinforce the boundaries.

    Naming Consistency3/5

    Two tools follow a clean verb_noun pattern (create_address, validate_address), but 'balance' is a bare noun and 'receive' is a bare verb, breaking the pattern. The names are still readable and unambiguous, so this is a mixed convention rather than a chaotic one.

    Tool Count4/5

    Four tools is small but each covers a genuinely separate primitive of a wallet (read, keygen, deposit, validate), so nothing feels padded. It sits at the lean edge of the appropriate range, with the missing spend path being a coverage gap rather than a count problem.

    Completeness2/5

    The surface covers receiving-side lifecycle only: there is no send/transfer tool, no fee or spend-gate operation despite 'spend gate' being referenced in the receive description. Other gaps (listing created addresses, transaction history) leave an agent unable to complete the most basic outbound wallet workflow.

    Maintenance

    ActivityActive
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers