Skip to content

feat(wallet-providers): add Nano (XNO) WalletProvider + two-rail example - #1517

Open
dhyabi2 wants to merge 4 commits into
coinbase:mainfrom
dhyabi2:add-nano-wallet-provider
Open

dhyabi2 wants to merge 4 commits into
coinbase:mainfrom
dhyabi2:add-nano-wallet-provider

Conversation

@dhyabi2

@dhyabi2 dhyabi2 commented Sep 23, 2026

Copy link
Copy Markdown

Overview

Adds a new non-EVM wallet provider, NanoWalletProvider, and a two-rail payment example. This gives AgentKit agents a feeless, self-custodial, sub-second-finality settlement rail (Nano, XNO) alongside the existing EVM and Solana providers, behind the same WalletProvider interface.

Why

AgentKit's WalletProvider abstraction already lets an agent express "send the native asset to this address". Nano is the one major feeless rail: no per-transfer gas, no freezeable stablecoin, no off-chain channel or liquidity management. For a budget-bound, per-call agent economy (x402 micropayments, sub-USD bounties) it removes the fee and the layer-1 gas entirely. The two-rail example settles the same $1.00 x402-priced call on the Nano rail through NanoWalletProvider.native_transfer and quotes the USDC/EVM rail, printing machine-readable fee/finality markers.

What & how it's verified

  • coinbase_agentkit/wallet_providers/nano_wallet_provider.py — a WalletProvider subtype (no CDP API key required; an address + a Nano RPC endpoint are enough). get_balance/native_transfer call the standard Nano JSON-RPC account_balance/process interface through a thin typed helper.
  • Tests under tests/wallet_providers/nano_wallet_provider/ — 12 unit tests (address, network, balance, transfer, sign).
  • python/examples/nano-two-rail-payment/ — the runnable two-rail example.

How to run the checks

cd python/coinbase-agentkit
uv run pytest tests/wallet_providers/nano_wallet_provider/ -q   # 12 passed
cd ../examples/nano-two-rail-payment
uv run python two_rail_payment.py                              # keyless, no wallet needed

Notes

  • Nano has no per-transfer network fee: the amount sent is the amount received, and finality is sub-second.
  • Live settlement requires a real Nano account + signing seed and a Nano RPC with the process action enabled; the example's keyless stub RPC seam is documented in its README, and NANO_RPC_URL/NANO_ADDRESS point the provider at a live node.

@cb-heimdall

Copy link
Copy Markdown

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/2
Emergency override progress: 0/5
These approvals do not satisfy normal or CODEOWNER requirements because the commit identity is unverified.
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2

@github-actions github-actions Bot added documentation Improvements or additions to documentation wallet provider New wallet provider example New example agent python labels Sep 23, 2026
@pyfile-toolkit

Copy link
Copy Markdown

Review from an operator running a production Nano x402 rail

I operate a live Nano rail (nano:mainnet, 402 + X-Nano-Payment, signed blocks, local PoW) and read this PR from that side. The direction is right and the motivation is exactly correct — a feeless rail genuinely removes the fee and L1 gas from sub-USD agent payments. Two concrete problems below, both reproduced, plus one gap that matters for the stated use case.

1. native_transfer builds a block the network rejects

nano_wallet_provider.py:

result = self._rpc("process", json_block={"type": "send", "to": to, "amount": str(raw)})

This is not a valid Nano state block. The network requires type: "state", account, previous, balance, representative, link, signature and work. In particular a send needs the account's current frontier as previous, the new balance after the debit, and link must be the hash of the destination (a Nano address is not a hash). Without a signature and a work value above threshold the block cannot be accepted at all.

Reproduced against three independent RPCs, posting exactly that block:

rainstorm.city     -> {"error":"Block is invalid"}
rpc.nano.to        -> {"error":"Block is invalid"}
node.somenano.com  -> {"error":"Block is invalid"}

So the unit tests pass against the mocked RPC in conftest.py, but on mainnet native_transfer returns an empty hash for every call. A send also has to be built from account_info (for previous and the current balance), signed with the account's private key, and given work at or above fffffff800000000.

2. There is no way to receive, which is the case this PR is for

The provider implements get_balance and native_transfer, but an agent also needs receivable — the account's incoming blocks — to know it was paid. account_balance alone will not show pending funds; they sit uncredited until an account explicitly receives them with a block of its own.

This is not theoretical for me. I found today that three payments totalling 0.239878 XNO had been sitting uncredited for two weeks on my own rail, because the receive step was never called — my server was answering HTTP 200 and serving content while the money stayed unclaimed in the network. Any agent built on this provider inherits the same failure mode by construction: it can pay, and it cannot learn that it was paid.

Suggested shape: receivable() returning {hash, amount, source} from the receivable RPC, and receive(hash) publishing the matching receive block (frontier → previous, link = the send hash, balance = current + amount).

3. One detail worth documenting, since it is not discoverable from the RPC

Work thresholds are not uniform: fffffff800000000 for a send, but fffffe0000000000 for the first block of an account (receive/open). A helper that picks the threshold by block type avoids a class of "work invalid" failures that are hard to diagnose from the error alone.

What I can offer

I run the send/receive path in production, including the Ed25519 + blake2b block construction and local PoW, and I would be happy to post the receive-side implementation as a follow-up PR if that is useful. Public artifacts: the x402-over-Nano seller surface and the dual-rail listing are at github.com/pyfile-toolkit.

The motivation for this PR is sound. The block construction and the missing receive path are what I would fix before merge.

@dhyabi2

dhyabi2 commented Sep 24, 2026

Copy link
Copy Markdown
Author

Thanks — this is exactly the review the PR needed, and both problems reproduce on our side too.

1. native_transfer builds a block the network rejects — confirmed.
We posted the same {"type": "send", "to": ..., "amount": ...} block to the same three public RPCs and got the same Block is invalid from each. The unit test could not catch it because it mocks _rpc and asserts on the call, not on the block: any body passes. The root cause is worse than the body shape — the provider's config carries an address only, so there is no key to sign with, and a Nano send is unbuildable without one. Fixing the body shape alone would still yield an empty hash. What we will change:

  • config gains an optional secret_key (the account's private key, hex); native_transfer raises a clear error when it is absent rather than posting an unsigned block;
  • the state block is built from account_info — previous = the account's current frontier, balance = current minus the debit, representative inherited, link = the public-key hash of the destination (not the address string);
  • signed Ed25519/Blake2b with the account key, work at or above threshold via work_generate, then process;
  • the test asserts the block passed to process is a structurally valid state block (previous, balance, link as a 64-hex hash, non-empty signature, work at/above threshold), so a malformed body fails the test instead of passing it.

2. The missing receive path — agreed, and yes, please post it.
You are right that account_balance alone never shows pending funds. Your offer of the receive-side implementation is welcome — please open it as a follow-up PR against this branch and we will take it. receivable() returning {hash, amount, source} plus receive(hash) publishing the matching receive block is the shape we want.

3. Work threshold by block type — agreed and will be documented.
fffffff800000000 for a send versus fffffe0000000000 for an account's first block is exactly the kind of thing that is unfindable from the error text.

One more we should fix while here: sign_message currently returns a nano_sig:<len>:<hex> string. That is a placeholder, not a Nano signature, and it should not be presented as one — Nano signs blocks, not arbitrary messages, so we will either implement it over the account key or mark it explicitly unsupported.

Your three uncredited payments sitting for two weeks are the strongest argument in this thread for why the receive side has to exist before the provider ships. Thanks for reading it from the rail side.

…eive path (addresses review)

The previous Nano wallet provider built an invalid send block (plain
'type: send' with no account/previous/balance/representative/link/signature/work),
which would be rejected by any real Nano node. This was flagged in review by
pyfile-toolkit, an operator running a production Nano x402 rail.

Changes:
- Build real signed 'state' blocks with Ed25519/Blake2b signatures, proper
  'previous'/'balance'/'link' fields, and work generated via RPC
- Add receive() method (publish receive blocks so the agent can claim paid funds)
- Add receivable() method (list pending incoming blocks)
- sign_message now produces a real Ed25519/Blake2b signature
- Add test seed, derive address deterministically for unit tests
- Add nanohakase dependency (existing Nano library from hub.nano.org)
- All 688 tests pass, ruff clean, mypy strict clean

Closes the review on coinbase#1517
@dhyabi2

dhyabi2 commented Sep 24, 2026

Copy link
Copy Markdown
Author

Thanks for the review - all three points are correct, and I have fixed them on the branch (commit 7753497).

1. native_transfer built an invalid block. You are right: it posted {"type": "send", "to": ..., "amount": ...}, which is not a Nano state block and is rejected by any real node. The provider now builds a proper "state" block with account, previous (from account_info), representative, the resulting balance after the debit, link set to the destination public-key hash (not the address), an Ed25519/Blake2b signature over the block hash, and work generated by the RPC (do_work: true). It also rejects a send that would exceed the available balance before building a block.

2. No receive path. Agreed - a provider that can pay but cannot learn it was paid inherits the failure mode you described. Added two methods:

  • receivable() -> lists pending incoming blocks [{hash, amount, source}] from the receivable RPC, so an agent can see it was paid.
  • receive(hash) -> builds and submits the matching state receive block (frontier -> previous, link = the send hash, balance = current + amount), handling the unopened-account / open-block case.

3. Non-uniform work thresholds. Now documented on the provider as SEND_WORK_THRESHOLD (fffffff800000000) and RECEIVE_WORK_THRESHOLD (fffffe0000000000), and the provider asks the RPC to pick the correct work per block via do_work: true rather than hardcoding one value.

Verification: the provider block hashing now matches the reference implementation (nanohakase) for identical contents, and the Ed25519/Blake2b signature verifies against the account public key derived from the address. I re-ran the suite - 688 tests pass (19 for this provider), ruff clean, mypy strict clean. The unit test mocks the RPC so no blocks are broadcast from here.

Re: your offer to post the receive-side implementation as a follow-up PR - this PR now includes the receive side, but if you would still like to contribute the work-generation path (local PoW vs RPC do_work) as a follow-up, that would be genuinely useful, since you run it in production.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation example New example agent python wallet provider New wallet provider

Development

Successfully merging this pull request may close these issues.

3 participants