Skip to main content

Pre-broadcast Bitcoin transaction privacy scorer

Project description

SiriScore — Bitcoin Transaction Privacy Scorer

Pre-broadcast Bitcoin transaction privacy analysis. Accepts a PSBT, raw tx hex, or txid and returns a scored privacy report with actionable findings — before you sign.

CI


Requirements

  • Python 3.11 or later
  • Internet access is optional — H3 (address reuse) and H4 (UTXO age) require network lookups; all other heuristics are fully offline

Installation

From TestPyPI (current release)

pip install --index-url https://test.pypi.org/simple/ \
            --extra-index-url https://pypi.org/simple/ \
            siriscore

Package page: https://test.pypi.org/project/siriscore/0.1.0/

The --extra-index-url pypi.org flag lets pip pull the runtime dependencies (fastapi, rich, requests, etc.) from the main PyPI index, since they are not mirrored on TestPyPI.

From source (development)

git clone https://github.com/nkatha23/siriscore.git
cd siriscore
pip install -e ".[dev]"



OR

python3 -m pip install -e ".[dev]"

The -e flag installs in editable mode — changes to scorer/, api/, or cli.py take effect immediately without reinstalling.


Web UI

1. Start the server

uvicorn api.main:app --reload

OR

python3 -m uvicorn api.main:app --reload

The server starts at http://localhost:8000. Keep this terminal open while you use the UI.

To bind to a specific port:

uvicorn api.main:app --reload --port 8080

2. Open the browser

Go to http://localhost:8000. You'll see the SiriScore input form.

3. Analyse a transaction

  1. Pick an input type — use the PSBT / Raw Tx / Txid tabs to tell SiriScore what you're pasting
  2. Paste your transaction — base64 PSBT, raw hex, or a 64-character txid
  3. Click Analyse Transaction

The results panel shows:

  • Privacy Score (0–100) with a colour gauge and verdict (Critical / Poor / Fair / Good / Excellent)
  • Findings — heuristics that fired, with detail and a fix suggestion each
  • Checks — every heuristic with its status (pass / fail / skipped / unavailable)
  • Coin labels — any UTXO labels loaded from Sparrow Wallet
  • What to do next — prioritised action list based on the findings

4. Network checks (H3 + H4)

By default the Enable network checks toggle is on when the backend is running. This allows the server to query mempool.space (with blockstream.info as fallback) for:

Heuristic What it looks up Why it matters
H3 — Address reuse Transaction history for each input address Reusing an address links all past and future activity to one identity
H4 — UTXO age clustering Block height each input was confirmed in UTXOs confirmed within 6 blocks of each other suggest they come from the same wallet

To disable network checks: uncheck the toggle before clicking Analyse. H3 and H4 will show as skipped in the Checks panel — no data leaves your machine.

To import coin labels from Sparrow Wallet, click Import labels from Sparrow Wallet and select your exported .jsonl labels file.

Sparrow label import supports Sparrow Wallet v1.8+ BIP329 JSONL exports (type + ref + label records), including transaction (tx), address (addr), and UTXO (output/input) labels. It also keeps backward compatibility with the older simple {"txid:vout": "label"} JSON shape used by early SiriScore fixtures. The parser is covered by local fixtures based on Sparrow's BIP329 JSONL format and cross-checked against Sparrow Wallet 2.5.2's WalletLabels export implementation.


Testing Sparrow label import

Option A — use a sample labels file

Create sparrow-labels.jsonl:

{"type":"tx","ref":"93c98c2a742373460e74b7d3b39ba30283b14476df835916d0a3f60dfc988e0d","label":"Exchange withdrawal","tag":"tainted"}
{"type":"addr","ref":"bc1qalum72s7tmt39y32fv5r06qvu9nz5phdjfcw8h","label":"KYC receive address","tag":"tainted"}
{"type":"output","ref":"35cebb10f6d6129716effcd69eb43df40669f4b27533be0857299fec0c52a976:1","label":"Specific labelled coin","tag":"tainted"}

Run the dashboard:

python3 -m uvicorn api.main:app --reload

Open http://localhost:8000, click Import labels from Sparrow Wallet, select sparrow-labels.jsonl, paste this txid, and analyse:

93c98c2a742373460e74b7d3b39ba30283b14476df835916d0a3f60dfc988e0d

Expected result: H8 fires because this transaction spends inputs matching the imported transaction, address, or UTXO labels.

Option B — export labels from a real Sparrow wallet

Use a tiny test amount only. A Sparrow software wallet is fine for testing, but do not treat a quick test wallet as long-term secure storage.

  1. Download Sparrow from the official site and verify the download if you can: https://sparrowwallet.com/docs/
  2. Open Sparrow and choose a server connection. For testing, a public server is easiest, but it reduces privacy because wallet public key information is shared with the server.
  3. Click Create New Wallet.
  4. Enter a wallet name, for example siriscore-test.
  5. Use the defaults: Single Signature and Native Segwit (P2WPKH).
  6. Choose New or Imported Software Wallet.
  7. Create a BIP39 mnemonic, for example 12 words.
  8. Finish setup and click Apply.

Add labels in Sparrow:

  • Address label: go to Addresses or Receive, pick a receive address, and add a label such as KYC test receive address.
  • Transaction label: after receiving a tiny amount, go to Transactions and label the transaction, for example Test transaction label.
  • UTXO label: go to UTXOs and label the coin, for example Specific labelled coin.

Export the wallet labels from Sparrow. The modern export format is BIP329 JSONL, one JSON object per line:

{"type":"tx","ref":"<txid>","label":"Test transaction label"}
{"type":"addr","ref":"bc1q...","label":"KYC test receive address"}
{"type":"output","ref":"<txid>:0","label":"Specific labelled coin"}

Import the .jsonl file into SiriScore, then paste either a txid from Sparrow's Transactions tab or a PSBT exported from Sparrow. A PSBT is the best test because it carries richer input metadata.

Expected result: if the transaction spends a labelled input, H8 fires and the Coin labels section shows only labels relevant to the analysed transaction inputs.

For example, if you label 35ce...:1 and analyse 93c98c..., it should match because 93c98c... spends output 35ce...:1. If you analyse 35ce... itself, that label may not match because 35ce...:1 is an output of that transaction, not an input being spent by it.


Offline demo (no server required)

open web/index.html          # macOS
xdg-open web/index.html      # Linux
start web/index.html         # Windows

The page detects it is running as a local file and switches to built-in mock data automatically. No backend, no network, no install needed. Useful for exploring the UI or sharing a demo.


CLI

# Score a PSBT
btc-privacy-check --psbt <base64>

# Score a raw transaction hex
btc-privacy-check --rawtx <hex>

# Score by txid (fetches raw tx from mempool.space / blockstream.info)
btc-privacy-check --txid <txid>

# Import Sparrow labels before scoring
btc-privacy-check --psbt <b64> --import-sparrow sparrow-labels.jsonl

# Fail with exit code 1 if score is below a threshold (CI use)
btc-privacy-check --psbt <b64> --fail-below 60

# Machine-readable JSON output
btc-privacy-check --psbt <b64> --json

# Both: JSON output and exit 1 if score < 60
btc-privacy-check --psbt <b64> --fail-below 60 --json

The siriscore alias works identically:

siriscore --psbt <b64>

Exit codes

Code Meaning
0 Scored at or above threshold (or no threshold set)
1 Score is below the --fail-below threshold
2 Parse error — invalid PSBT/hex/txid or network failure

With --json, a parse error writes {"error": "<reason>"} to stdout so scripts can distinguish it from a score failure.


CI integration

Gate a build on privacy score using --fail-below:

- name: Check transaction privacy
  run: btc-privacy-check --psbt "${{ env.PSBT }}" --fail-below 60 --json

Python library

import siriscore

# Score a PSBT, raw tx hex, or txid — all offline by default
report = siriscore.score("cHNidP8BA...")

print(report.score)              # 0–100
print(report.input_count)        # number of inputs
print(report.output_count)       # number of outputs

for f in report.findings:
    print(f.heuristic_id, f.severity.value, f.title)
    print(f"  {f.detail}")
    print(f"  Fix: {f.suggestion}")

# With coin labels — H8 fires if a tainted UTXO, transaction, or address label matches an input
import_labels("sparrow-labels.jsonl", source="sparrow")
report = score("cHNidP8BA...")

Privacy-first by default

Network lookups (H3, H4) are opt-out by default. No address or txid is ever sent to a third party unless you explicitly request it:

# Default — fully offline, H3/H4 skipped
report = siriscore.score("cHNidP8BA...")

# Opt in to network checks (queries mempool.space with blockstream.info fallback)
report = siriscore.score("cHNidP8BA...", lookup=True)

Coin labels and H8

Labels are stored locally in ~/.utxo-privacy-scorer/labels.db (SQLite — no cloud, no sync).

# Import labels from a Sparrow Wallet export
n = siriscore.import_labels("sparrow-labels.json")
print(f"Imported {n} labels")

# Score — H8 fires if any input UTXO is labelled "tainted"
# H8 also caps the final score at 40 regardless of other findings
report = siriscore.score("cHNidP8BA...")

Label tags: tainted (triggers H8 score cap), coinjoin, clean, unknown.

Explicit input type

from siriscore import score_as

report = score_as("cHNidP8BA...", input_type="psbt")
report = score_as("0200000001...", input_type="rawtx")
report = score_as("a1b2c3...",    input_type="txid", lookup=True)

REST API

Start the server:

uvicorn api.main:app --reload

POST /score

curl -s -X POST http://localhost:8000/score \
  -H "Content-Type: application/json" \
  -d '{"input": "cHNidP8BA...", "input_type": "psbt", "lookup": false}' \
  | python3 -m json.tool

Request body:

Field Type Default Description
input string required PSBT (base64), raw tx hex, or txid
input_type string "psbt" "psbt", "rawtx", or "txid"
lookup boolean true Enable H3/H4 network checks

Response fields: score, findings, checks, labels, input_count, output_count, psbt_version.

GET/POST /labels

Manage coin labels in the local SQLite store.

POST /labels/import

Upload a Sparrow Wallet JSON export to bulk-import labels.


Running tests

# All tests
python3 -m pytest

# Single file
python3 -m pytest tests/test_labels.py

# Single test
python3 -m pytest tests/test_heuristics.py::TestH2RoundAmount::test_fires_on_round_output

# With coverage
python3 -m pytest --cov=scorer

Heuristics

ID Name Severity Weight Requires network
H1 Script type mismatch Critical 25 No
H2 Round payment amount Warning 15 No
H3 Address reuse on input Critical 20 Yes
H4 UTXO age clustering Warning 10 Yes
H5 High input count (CIOH) Warning 10 No
H6 Dust input present Warning 10 No
H7 Non-BIP69 ordering Info 5 No
H8 Tainted labelled UTXO Critical 25 No

Score = 100 − sum of triggered weights, floored at 0.
H8 cap: when H8 fires the final score is additionally capped at 40, regardless of other heuristics.

Check statuses

Each heuristic produces a check in the report, even when it does not fire:

Status Meaning
pass Heuristic ran and found no issue
fail Heuristic fired — finding recorded
skipped Network check deliberately not run (lookup=False)
unavailable Could not run — input data missing (e.g. no prevout info in PSBT)

Project structure

scorer/          ← standalone Python library
  __init__.py    ← public API: score(), score_as(), import_labels()
  report.py      ← data models: Severity, Finding, Check, Report
  parser.py      ← PSBT / raw tx / txid parser
  lookup.py      ← mempool.space + blockstream.info with in-process cache
  labels.py      ← SQLite label store
  heuristics/    ← one module per heuristic: check(tx, psbt_meta) → Finding | None
siriscore/       ← re-export shim (import siriscore == import scorer)
cli.py           ← argparse + rich CLI
api/main.py      ← FastAPI backend
web/
  index.html     ← HTML shell
  style.css      ← all styles (CSS tokens, layout, glossary, responsive)
  app.js         ← all JavaScript (fetch, render, glossary, mock fallback)
tests/           ← pytest suite

Development

# Lint
ruff check scorer/ api/ tests/

# Type-check
mypy scorer/

# Build wheel
python3 -m build --wheel

# Upload to TestPyPI
twine upload --repository testpypi dist/*

# Upload to PyPI
twine upload dist/*

Continuous integration runs on every push and pull request to dev and main via .github/workflows/ci.yml (tests → ruff → wheel build).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

siriscore-0.1.0.tar.gz (35.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

siriscore-0.1.0-py3-none-any.whl (26.1 kB view details)

Uploaded Python 3

File details

Details for the file siriscore-0.1.0.tar.gz.

File metadata

  • Download URL: siriscore-0.1.0.tar.gz
  • Upload date:
  • Size: 35.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for siriscore-0.1.0.tar.gz
Algorithm Hash digest
SHA256 16437dcf4c3f3acae9e961a6698816e4453e4dced84f717744951088b1faf849
MD5 139b1a10aca268fe3cf44d002b73d6c5
BLAKE2b-256 a2ea5d8aa77d2911451b67f2fad755dd70a8d8f51e2c2a3995b4ba2f49bc2408

See more details on using hashes here.

File details

Details for the file siriscore-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: siriscore-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 26.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for siriscore-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7cc0f20af4dd569d8945d2f90b777e37fb17285a7e5e85adc8233bb2d9c6a9fb
MD5 677908e9b6badd01566d3400a049b170
BLAKE2b-256 afda1ff9cf97c78b951d12b23298400e59e68ee39f5088b98e4cedf314ee2343

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page