Skip to main content

bip322-audit

Proof of control of a wallet's coins at a block: one BIP-322 proof per funded address, a block stamp inside the signed message, and the on-chain checks an auditor runs on their own node. Built on bip322-core, which does the BIP-322 work and never talks to a node; everything that does lives here, and only through bitcoin-cli.

Two roles:

  • Owner: snapshot (or prove, for given addresses), sign on the cosigners' devices, finalize. Needs a node with the wallet loaded (watch-only is enough). Everything lands under <wallet>/snapshots/ in the directory bip322 init made.
  • Auditor: verify, against any node that has the chain; holdings to look up single outputs or addresses. No wallet, no index, no key material, and nothing about the owner's wallet beyond the proven addresses.

The commands: stamp, snapshot, prove, finalize, verify, holdings, help. With bip322-core installed they are also reachable as bip322 audit ....

Install

One line, into a fresh venv, with bip322-core pulled in:

python3 -m venv ~/.bip322 && ~/.bip322/bin/pip install "bip322-audit[kernel]"
export PATH="$HOME/.bip322/bin:$PATH"

Python 3.11 or newer (on Ubuntu 22.04: apt install python3.12 python3.12-venv from the deadsnakes PPA, then python3.12 -m venv ~/.bip322); leave out [kernel] on anything but CPython 3.12 / Linux x86_64.

For a reproducible, hash-pinned install (what an auditor should do), clone and use the setup script:

git clone https://github.com/embeddednation/bip322-audit.git && cd bip322-audit
./setup.sh                          # venv, hash-pinned dependencies, bip322-core at the pinned tag, tests
export PATH="$PWD/.venv/bin:$PATH"

setup.sh installs bip322-core from its git repository at the tag named by CORE_REF in the script. ./setup.sh --core ../bip322-core installs a local checkout instead, which is the development setup and also the auditor's: clone both repositories at the tags the owner names, check the commit hashes out of band, and run verify.

The whole yearly flow across the three packages is in bip322-report's handbook.

Workflow

For "we controlled these coins as of block N", repeatable whenever coins move:

bip322 init -w treasury               # once: this directory holds the wallets (bip322.toml)
bip322 audit snapshot                 # the node wallet's own descriptor
#   -> treasury/snapshots/2026-09-14-912345/: snapshot.json, message.txt, to_sign/to_sign-01.psbt ... one per funded address
#   sign every PSBT on the cosigners' devices, put the results into its signed/
bip322 audit finalize                 # the newest snapshot -> treasury-2026-09-14-912345-proofs.json (hand this to the auditor)
bip322 audit verify --report audit-report.json

The wallet is -w NAME, else the wallet directory the command runs in, else wallet in bip322.toml, else the node's one loaded wallet; finalize and verify take a snapshot's name (finalize 2026-09-14-912345) or a path when the newest is not the one meant, and verify a proofs file. -o DIR writes a snapshot elsewhere. See Where everything lives.

finalize also asks the node wallet the coins came from (recorded in snapshot.json) which listed outputs have been spent since, and records the spending transactions in the proofs file. If coins move between the snapshot and the audit, re-run finalize before handing the proofs file over; the proofs themselves do not change. --offline skips that step and keeps the spends an earlier run recorded. A PSBT that cannot be used is reported with its address and file names, and files in to_sign/ and signed/ that were not used are listed.

The node is reached with --cli CMD (default bitcoin-cli). CMD is split like a shell command line, so a path with spaces is quoted inside it: --cli "'/opt/my node/bitcoin-cli' -signet". --timeout SECONDS bounds every node call (default 600); a UTXO-set scan the tool gave up on keeps running on the node. Chains: main, test, testnet4, signet, regtest.

snapshot reads the wallet's descriptor from the node wallet (listdescriptors; --descriptor FILE overrides and is cross-checked against the node), takes the block six behind the tip (--depth) as the stamp and the snapshot height: the message ends with block: HEIGHT HASH TIME taken from that block, and only outputs confirmed at that block are listed. Coins come from listunspent on the node wallet or, when the node has no wallet, from a scantxoutset of the descriptor (minutes on mainnet; the command says so before it starts; --source forces either). The stamp, the coins, their heights and block hashes are read under one tip: if a block arrives meanwhile, they are read again. The template accepts {date}, {time}, {height}, {hash} (date and time are the stamp block's header time, UTC; text in bip322.toml sets the default); anything else in braces is refused, as is a template with a block: line of its own, and the message is checked against hardware signers' message rules.

snapshot covers one descriptor (wsh(multi/sortedmulti) or wpkh). Its summary and snapshot.json say which (descriptor), and under left_out how many outputs and how much the node listed that the snapshot does not, and why: coins of another descriptor in the same node wallet, coins on addresses an earlier snapshot proves. Descriptors of a kind it does not cover are named in notes. The snapshot's directory is <date>-<height> under the wallet's snapshots/, with -2, -3 ... when that exists; -o DIR --force rewrites a snapshot that holds no signed PSBTs and no proofs file yet, and nothing else.

The proofs file names addresses, not a wallet: the message, the stamp, and per address the proof and its outputs, plus the policy string (2 of 3). It is built from a fixed list of fields (tool, chain, stamp, message, message_hex, policy, finalized_utc, total_sat, total_btc, spends, spends_utc, and per proof address, signature, variant, total_sat and the outputs' txid, vout, amount_sat, height, blockhash), with the proofs in address order and only smp proofs. No descriptor, no xpubs, no derivation paths and no node wallet name go in. The proofs stand per address, and the xpubs would let the auditor derive every address of the wallet, past and future, which no check of the claim needs. snapshot.json, the owner's copy, keeps all of it. The file's name, <wallet>-<date>-<height>-proofs.json, says what it is wherever it ends up, and it keeps that name in the snapshot, beside a statement and at the auditor's.

verify re-checks everything on the auditor's node: each BIP-322 signature, the stamp block (getblockheader: height, time, in main chain), that the document is consistent with the signed message, and each listed output. An output still unspent is checked with gettxout (amount, address, creation height at or before the stamp), which also shows it was unspent at the stamp. An output spent since is fetched by the block hash the snapshot recorded (no -txindex needed): that shows it existed at the stamp with the claimed amount and address. Whether it was still unspent at the stamp needs the spending transaction, which only an address index or the owner's wallet knows; finalize records it in the proofs file from the node wallet's history (listsinceblock from the stamp block), and verify checks that it really spends the output and was confirmed after the stamp block. A spend at or before the stamp is a contradiction (spent_before_snapshot). A spent output with no recorded spend, or with one the node cannot find or whose block left the main chain, is spent_time_unknown: it existed, and nothing shows when it was spent, so it is not counted as held. The report's rows are built from what the node answered; nothing else in the proofs file is copied into them. Totals are computed from the listed outputs: claimed, held at the stamp (shown by the node) and unspent now. A total_sat that disagrees, an output or an address listed twice, a recorded spend that does not spend its output, and a document with no proofs are problems of the document.

The result is one of three:

  • OK: the signatures and the stamp check out, the document is consistent, and every listed output is shown held at the stamp block (unspent now, or spent after the stamp by a checked spend).
  • INCOMPLETE: nothing failed and nothing is contradicted, but some listed output is not shown held at the stamp block. The owner re-runs finalize on the node wallet to record the spends.
  • FAILED: a signature, the stamp or the document does not check out, or the node contradicts a listed output.

In the JSON report the verdict is result (ok, incomplete, failed). The key ok is true only for ok; totals.held_at_stamp_sat is what the node showed held.

--offline verifies signatures and the document only.

Exit codes

command 0 1 2 3
verify result OK result FAILED could not run result INCOMPLETE
stamp, snapshot, prove, finalize, holdings, help done not used could not run not used

"Could not run" is bad input (an unknown placeholder, a file that is not a proofs document, a directory that exists), a node that cannot be reached or answers something that is not JSON, or a snapshot that cannot be finalized; the message is one line on stderr starting with error:.

Completeness (that the listed addresses are all the holdings in scope) is not something a key or a scan can establish, since nothing rules out a second wallet. It comes from the audited party's representation and from reconciling one year's spends to the next year's proofs, which the recorded spends make possible.

examples/audit_walkthrough.sh [DIR] runs the whole thing on a throwaway regtest node, including spending a coin after the snapshot (INCOMPLETE until finalize is re-run). It writes into a new directory and refuses one that exists and is not empty.

Checking holdings on chain

bip322 audit holdings 7a1b...:0 3c9d...:1 --at 912345    # by output: a direct lookup, instant
bip322 audit holdings bc1q... --at 912345                 # by address: a UTXO-set scan, minutes on mainnet

For each output: the scriptPubKey it is locked to, its amount, and whether it is unspent; with --at, whether it was held at that block (an output confirmed at or before the block and unspent now was; gettxout only finds unspent outputs). The JSON adds the address and the confirmation block and time. This is how a reader of a statement checks its closing holdings. Coins spent since cannot show; the owner's records name them and verify checks those. A bech32 address may be given in upper case.

Where everything lives

A directory of your choice, made with bip322 init (the same as bip322 audit init): it writes bip322.toml, the file that marks the directory and holds the defaults, and from then on every command run in that directory or below finds it, as git finds its repository. There is no default place; --dir DIR or BIP322_DIR name it from elsewhere.

mkdir ~/bip322 && cd ~/bip322
bip322 init -w treasury               # -> bip322.toml: wallet = "treasury", every other key a comment to switch on
~/bip322/
  bip322.toml
  treasury/
    snapshots/
      2026-03-02-880412/                  # a prove during the year
      2026-09-14-912345/                  # a full snapshot
        snapshot.json  message.txt  to_sign/  signed/
        treasury-2026-09-14-912345-proofs.json
    statements/                           # bip322-report's
  holders/                                # bip322-report's: one ledger per holder, over that holder's wallets

-w NAME names the wallet: its directory, and the node wallet of the same name (or the one node names in its [wallets.NAME] table of bip322.toml, for a node wallet whose name is long or no directory name). Without -w, the wallet directory the command runs in names it, else wallet in bip322.toml, else the node's one loaded wallet does; inside a snapshot's directory, finalize and verify mean that snapshot. A second wallet is a second directory, created when first written to. The snapshots hold the PSBTs, which carry the wallet's xpubs: they stay with the holder.

In bip322.toml, top-level keys are defaults for every wallet, a [wallets.NAME] table is for one, and the command line beats both:

wallet = "treasury"
text = "Proof of control {date}"
cli = "bitcoin-cli -rpcconnect=10.0.0.5"

[wallets.treasury]
node = "treasury-2of3-main"

init writes the file with every key the tools read as a comment to switch on, and -w NAME switches wallet = NAME and its table on. An existing file is left alone.

Keys this package reads: wallet, cli, timeout, text, and node per wallet; bip322-report reads more.

Over the years the snapshots accumulate, and their proofs files are what --skip-proven reads:

bip322 audit snapshot --skip-proven
#   -> snapshots/<date>-<height>/ with only the outputs no earlier snapshot of the wallet proves (new change, new deposits)
bip322 audit snapshot --text "Proof of control, audit FY2026, {date}"
#   -> every output the wallet holds, after the period's end, under a message that names the audit

The block stamp in every message is the timestamp an audit needs: it shows the proof was made after that block, it can be checked on any node at any later date, and it cannot pass for another year. The auditor's request is therefore simply "a full snapshot with a stamp after the period's last block"; their engagement reference may go in the text if they want it in their file.

A third way, for an address that holds nothing yet:

bip322 audit prove bc1q...change...
#   -> a snapshot for exactly that address; refused if it is not the wallet's

That is the check to run on the change address of a spend before broadcasting it: sign, finalize, and a valid proof means the quorum controls where the change goes. A proof is about an address, so every output paid to it later is covered too, and --skip-proven skips addresses proven that way.

A proofs file under the wallet's snapshots that cannot be read (a truncated file) is named in a warning, since it proves nothing. A file still named proofs.json (earlier versions) is read as well.

Sign and finalize each as usual. bip322 audit verify checks one proofs file; bip322-report reads the whole of the wallet's snapshots to produce the statement of a period, in which every output is backed by a verified proof.

Layout

bip322audit/stamp.py      the block stamp: fetch, parse, compose the message, check
bip322audit/snapshot.py   coins at the stamp block (listunspent / scantxoutset), PSBT per address, the snapshot on disk (snapshot, prove)
bip322audit/audit.py      finalize (combine, finalize, self-verify, record spends) and verify (the report)
bip322audit/holdings.py   what outputs or addresses hold now, and at a block (holdings)
bip322audit/proofs.py     the proofs files in a directory tree: which addresses and outputs earlier proofs cover
bip322audit/datadir.py    the data directory: bip322.toml, finding it from below, the wallet directories, snapshot and proofs file names
bip322audit/rpc.py        bitcoin-cli as a subprocess
bip322audit/cli.py        the bip322-audit command, and bip322-init (what bip322 init runs)
bip322audit/testing.py    a throwaway regtest node for the regtest test and the walkthrough
tests/                    fake-node tests (test_audit.py, test_review_fixes.py), the pin test, and an end-to-end test on a real regtest Bitcoin Core
examples/audit_walkthrough.sh   the whole flow on a throwaway regtest node

The regtest test and the walkthrough need a Bitcoin Core binary: set BITCOIN_CORE_DIR, or run refcheck/fetch.sh in the bip322-core checkout the venv was set up from. Without one the test skips.

CI checks out bip322-core at the pinned tag.

Metadata

Release files for bip322-audit 0.17.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for bip322-audit 0.17.0
File Size Uploaded
bip322_audit-0.17.0.tar.gz 71.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for bip322-audit 0.17.0
File Interpreter ABI Platform
bip322_audit-0.17.0-py3-none-any.whl Python 3 none any Details

Total release size: 127.6 kB

Release files / bip322_audit-0.17.0.tar.gz

Download URL bip322_audit-0.17.0.tar.gz
Size 71.5 kB
Tags Source
SHA-256 checksum
How to use checksums
bb09f0a31fdc531549285a2179db7ad8a88de7310f7a265b26e90c57edcfca6b
BLAKE2b-256 checksum
How to use checksums
fc4f0a37307cc162cd9a2d6c85f377ef7165132f91fd736f81a0cb620d248ab5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release files / bip322_audit-0.17.0-py3-none-any.whl

Download URL bip322_audit-0.17.0-py3-none-any.whl
Size 56.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e90c703155675808b509d6c44c1d03eae94a7d7c748721f240340b4e6a20c60b
BLAKE2b-256 checksum
How to use checksums
6cc632e98004e6a831830ebd62d04abfe38825462a4b722c5ca675d19f05d47e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

0.19.0

2 release files

0.18.0

2 release files

This release

0.17.0 This release

2 release files

0.16.0

2 release files

0.15.1

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.1

2 release files

0.13.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page