Exchange integration

This guide walks through deposits and withdrawals for NULL, in the order you set them up. It covers the node, the two wallet daemons, deposit addresses, confirmations, withdrawals, note consolidation and accounting. The method reference is at Wallet methods and Node methods. NULL currently trades on NoirTrade.

What is different

NULL has no transparent addresses and no balances on chain. Every output is shielded. A wallet finds its deposits by trial-decrypting outputs with its viewing key; the wallet daemon does this for you.

  • One key, many addresses. Every customer gets their own deposit address, all derived from one wallet. You never manage per-user keys.
  • Withdrawals are asynchronous. A transaction takes seconds to prove, so sendmany returns an operation id and you poll it.
  • Fees are fixed per action. A transaction has 2, 4, 8 or 16 actions at 10,000 units each. One withdrawal transaction pays up to fifteen recipients.
  • Amounts are integers. One NULL is 100,000,000 units. The RPC carries amounts as decimal strings in units.

Mainnet addresses start with null1, viewing keys with nullview1. On the test network they are tnull1 and tnullview1.

1. Run a node

Download nulld and null-wallet-rpc from the releases page. See Run a node for the full setup.

nulld run --network main --datadir /var/lib/null \
    --rpc 127.0.0.1:18444 --rpc-http 127.0.0.1:18446

--rpc is the control socket the wallet daemons sync from. --rpc-http serves JSON-RPC 2.0 for your own tooling. Both use the token the node writes to rpc.token in its data directory, sent as Authorization: Bearer.

TOKEN=$(cat /var/lib/null/rpc.token)
curl -s -H "Authorization: Bearer $TOKEN" \
  -d '{"jsonrpc":"2.0","method":"getblockchaininfo","id":1}' \
  http://127.0.0.1:18446/

syncing false and a height that matches other nodes (or the explorer) means the node is caught up. Keep getblockchaininfo in your monitoring: its branch, next_branch and upgrades fields announce scheduled network upgrades. See Network upgrades.

2. Create the wallets

Use two wallets:

  • a hot wallet that holds the spending key and signs withdrawals;
  • a watch-only wallet, made from the hot wallet's viewing key, that detects deposits on a host with no spending key.
export NULL_WALLET_PASSPHRASE='choose a long one'

nulld create --wallet /srv/hot/hot.redb --network main
# seed_phrase: ...   write it down; it restores this wallet anywhere
# address: null1...

nulld export-viewing-key --wallet /srv/hot/hot.redb --network main
# nullview1...

nulld create --wallet /srv/deposits/watch.redb --network main \
    --viewing-key nullview1...

3. Run the wallet daemons

null-wallet-rpc --wallet /srv/deposits/watch.redb --network main \
    --node 127.0.0.1:18444 --node-token-file /var/lib/null/rpc.token \
    --listen 127.0.0.1:18447

null-wallet-rpc --wallet /srv/hot/hot.redb --network main \
    --node 127.0.0.1:18444 --node-token-file /var/lib/null/rpc.token \
    --listen 127.0.0.1:18448

Each daemon writes its own token to wallet-rpc.token next to its wallet file, unless you pass --token (or set NULL_WALLET_RPC_TOKEN). The passphrase comes from NULL_WALLET_PASSPHRASE or a prompt. Each daemon syncs from the node every two seconds (--sync-interval).

The examples below use this helper. Set W to the token of the daemon you call:

W=$(cat /srv/deposits/wallet-rpc.token)
rpc() {
  curl -s -H "Authorization: Bearer $W" \
    -d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":$2,\"id\":1}" \
    "http://127.0.0.1:$3/"
}

rpc getwalletinfo '[]' 18447

synced true means the wallet's scanned_height equals the node's height. last_error is non-null when the node cannot be reached or a submission failed; alert on it.

4. Deposit addresses

Ask the watch-only daemon for one address per customer, labelled with your customer id:

rpc getnewaddress '["customer-10042"]' 18447

Response

{ "index": 1, "address": "null1...", "label": "customer-10042" }

Store the address and its index with the customer. Every address stays valid, and a customer can reuse theirs: addresses never appear on chain, so reuse costs no privacy.

You can also use one address with a memo per customer. Memos are 512 bytes and come back with every deposit. Per-customer addresses are more robust, because customers forget memos.

listaddresses returns every address you have handed out. getaddressinfo maps an address back to its index and label.

5. Detecting deposits

Poll the watch-only daemon:

rpc listreceived '[10, 1500]' 18447

The parameters are min_confirmations and since_height. Each entry has txid, position, height, confirmations, amount, address, address_index, label, memo, spent and locked.

Credit the customer named by label once confirmations reaches your threshold. Record the txid plus address_index pair (or the note position, which is unique) so you never credit the same note twice.

Confirmations. The network refuses reorganizations deeper than 200 blocks, so 200 confirmations is safe against anything the network accepts. Ten blocks (about twenty minutes at 120-second blocks) is a reasonable everyday threshold.

If a block is reorganized away, the wallet rolls back by itself and the note disappears from listreceived. Your reconciliation job should re-list from a height a few hundred blocks back and compare with what you credited.

gettransaction shows your wallet's side of one transaction: the notes it received, the notes it spent, and the net change.

rpc gettransaction '["<txid>"]' 18447

6. Withdrawals

Send from the hot daemon. Batch up to fifteen recipients per call:

W=$(cat /srv/hot/wallet-rpc.token)
rpc sendmany '[[{"address":"null1...","amount":"250000000","memo":"withdrawal 88231"},
                {"address":"null1...","amount":"1000000"}], 10]' 18448

Response

{ "operation_id": 17, "status": "queued" }

The second parameter is the minimum confirmations a note needs before it is spent. Then poll:

rpc getoperationstatus '[17]' 18448

status moves through:

StatusMeaning
queuedWaiting for the prover. The only state canceloperation accepts.
provingThe transaction is being built and proved.
submittedRecorded for broadcast. txid is set.
confirmedMined. height and confirmations are set.
failederror says why, most often insufficient funds.
cancelledCancelled while queued.

Store the txid once the operation is submitted, and read it again from the operation when you mark the withdrawal complete: a rebuild (below) produces a new transaction.

What the daemon handles for you:

  • Notes chosen for an operation are reserved, so a later send cannot pick them.
  • A submitted transaction is rebroadcast every sync until it appears in a block.
  • If it is not mined within 100 blocks, its anchor expires and the operation is rebuilt with a fresh anchor, up to five attempts in total.
  • Operations persist in the wallet file, so a restart resumes them.

Fees. estimatefee gives the fee for a number of recipients, plus change:

rpc estimatefee '[2]' 18448

Response

{ "fee": "40000", "actions": 4 }

This is a lower bound. Each action carries one spend and one output, so the class is set by whichever is larger: the notes spent, or the recipients plus change. Fifteen recipients plus change is 16 actions, 160,000 units. A one-recipient withdrawal that has to spend five small notes needs 8 actions. quotepayment takes the same parameters as sendmany and returns the exact fee, spends and actions for the wallet's current notes. Change returns to the hot wallet's main address.

Note fragmentation and consolidation

A transaction can spend at most 16 notes, and the wallet picks the largest notes first. When the hot wallet holds many small deposits, a large withdrawal can need more notes than one transaction can spend, and each extra note can push a payment into a larger, more expensive class. Merge small notes from time to time.

quoteconsolidation previews a merge; consolidate queues it. Both run on the hot daemon:

rpc quoteconsolidation '{"min_confirmations": 10}' 18448

Response

{
  "address": "null1...",
  "input_count": 3,
  "inputs": [12, 40, 41],
  "transactions": 1,
  "fee": "40000",
  "amount": "374960000"
}

inputs are note positions. Confirm with the quoted inputs and fee, so nothing changes between review and execution:

rpc consolidate '{"min_confirmations": 10, "inputs": [12, 40, 41], "max_fee": "40000"}' 18448

Response

{
  "address": "null1...",
  "input_count": 3,
  "inputs": [12, 40, 41],
  "transactions": 1,
  "fee": "40000",
  "amount": "374960000",
  "operation_ids": [18],
  "status": "queued"
}

How it works:

  • Eligible notes, from any address in the wallet, are grouped into batches of 2 to 16 inputs. Each batch becomes one self-payment to the main (index 0) address, minus its fee.
  • Notes reserved by other operations, notes below min_confirmations, zero-value notes and batches that cannot cover their fee are left out. A trailing single note is left alone.
  • If the quoted inputs are no longer available, or the fee exceeds max_fee, the call is rejected before anything is queued.
  • Each batch is a normal operation: track it with getoperationstatus, cancel it with canceloperation while queued. Once confirmed, you can consolidate again to merge the results further.
  • A request while the daemon is scanning or proving is rejected with a busy message; retry shortly. A watch-only wallet can quote but not consolidate.

From the command line, stop the daemon that holds the wallet file first, then:

nulld consolidate --wallet /srv/hot/hot.redb \
    --rpc 127.0.0.1:18444 --rpc-token-file /var/lib/null/rpc.token \
    --min-confirmations 10 --dry-run

--dry-run prints the quote and changes nothing. Without it, the command queues the batches, builds and records the transactions for broadcast, prints the operation ids, and exits without waiting for them to be mined. A wallet daemon or a later run resumes any outstanding operations. See Command line.

7. Balances and accounting

rpc getbalance '[10]' 18448

Response

{ "spendable": "...", "locked": "...", "pending": "...", "total": "..." }

spendable has enough confirmations and is not reserved. locked is reserved by pending operations. pending has too few confirmations. listunspent lists the notes behind those numbers, with the same fields as listreceived.

To prove a payment to a third party without revealing anything else, the sending wallet produces a payment disclosure with getpaymentdisclosure; any node checks it with verifypaymentdisclosure and reports the address, amount and memo it proves. This settles "you never paid me" disputes.

To check that a customer controls an address, encrypt a message to it with the node's createchallenge and ask them to return it; only the owner can read it (answerchallenge on their wallet). A watch-only wallet can answer, so you can prove ownership of your own deposit addresses the same way. See Selective disclosure.

8. Backup, restore and keys

  • Back up the seed phrase printed by nulld create. It restores the keys on any machine.
  • A wallet file can be copied while its daemon is stopped. Address labels and operations live in the file, not in the seed, so keep a copy of the file too, or re-create labels from your database after a restore.
  • To restore: nulld create --wallet <file> --phrase --network main (the phrase is read from NULL_SEED_PHRASE or prompted), then run the daemon and let it scan from genesis. On an existing wallet, rescan with a from_height scans again from that height.

9. Test drive on one machine

The test network has trivial proof of work, 5-second blocks and a 10-block coinbase maturity, and a lone test node mines without peers. That makes it a quick place to try every call above.

mkdir -p /tmp/null-test
export NULL_WALLET_PASSPHRASE=test
nulld create --wallet /tmp/null-test/hot.redb --network test
# address: tnull1...

nulld run --network test --datadir /tmp/null-test/chain \
    --rpc 127.0.0.1:18444 --rpc-http 127.0.0.1:18446 --mine tnull1...

null-wallet-rpc --wallet /tmp/null-test/hot.redb --network test \
    --node 127.0.0.1:18444 --node-token-file /tmp/null-test/chain/rpc.token \
    --listen 127.0.0.1:18448

The node mines to the hot wallet, so its coinbase notes become spendable after ten blocks. Create a watch-only wallet from its viewing key, run a second daemon on port 18447, and walk through deposits, sendmany, getoperationstatus and quoteconsolidation. For production, switch every command back to --network main.