Skip to content

About

Proof-of-concept shares/seedphrase scanning

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Bitcoin Vision

Bitcoin data ends up written out by hand: a recovery phrase on a card, a share of one on a paper strip in a safe. Getting it back into software means typing it out again, or printing a QR code beside it and hoping that survives too. Bitcoin Vision reads the handwriting itself, from a photograph, offline, with no network anywhere in the pipeline.

It is two model pipelines and the Rust that runs them.

Pipeline Reads Models Crate and example
Penlock shares a cut worksheet strip: twelve rows of six GF(29) symbols, with the identity code that says which share it is cell-reference.onnx, a 29-way cell classifier penlock-scan, examples/scan.rs
BIP39 word lists a handwritten English recovery phrase, numbered or not, in any column layout word-detector.onnx, text-recogniser.onnx and its dictionary, word-reference.onnx and its calibration bitcoin-vision-words, examples/scan.rs

Each linked document describes that pipeline's stages, what it returns and where it fails. Neither is the point of the repository; they are what it has.

Penlock shares

Penlock splits a BIP39 seed phrase into three shares with a printable paper wheel and no electronics. Any two recover the phrase; one on its own says nothing about it. This pipeline reads a photographed strip back: it finds the strip — by the shaded checksum boxes upstream's original worksheet prints, or by the corner fiducials and identity code of the registered one — cuts the card into one image per symbol, and returns a distribution over the 29 symbols for each — never a letter it is not entitled to be sure of. A per-word checksum catches a hand's slip on recovery.

The example app reading two Penlock share strips through the
            camera: the second share's row 8 cell 2 is corrected from V to U,
            the combined check updates, paper review is acknowledged, and the
            two shares load a key without revealing the phrase

Those are two of the real shares this project's test data was written on — by hand, on upstream's original worksheet, cut and photographed on a table — put in front of an emulator's camera. It plays faster than real time, as the other animation does, because how long an emulator takes over a photograph is not what is being shown. The recogniser reads V in share 2's row 8, cell 2; the person checks the paper and changes it to U. The combined check then reports an exact match for that row. Other uncertain rows still require checking both paper strips and ticking the acknowledgement before Use share 2 is enabled. The recovered phrase never appears: two shares in, a key fingerprint and which strips made it out.

The implementation is penlock-scan (photograph to symbols) and penlock (the arithmetic, and the wheel the paper version is). Both are plain Rust crates in this repository. How it works.

Download the blank registered Penlock worksheet (PDF). It has the black square scan guides on each strip. Print both A4 pages duplex, flipping on the long edge, at actual size / 100% (not “fit to page”). To regenerate it from the sheet renderer:

cargo run --locked -p penlock-scan --features print --example print_sheet -- docs/penlock-registered-worksheet.pdf

Using the share library

bitcoin_vision::penlock is penlock-scan. With bundled-models it embeds cell-reference.onnx; otherwise Scanner::new(ModelBytes { cells }) takes the classifier's bytes.

use bitcoin_vision::penlock::{ScanOptions, Scanner};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let scanner = Scanner::bundled()?;
let photo = std::fs::read("share-strip.jpg")?;
for strip in scanner.scan_all(&photo, ScanOptions::default())?.document().observations {
    // strip.kind, strip.share, strip.quad, strip.confidence, strip.text,
    // and strip.words: twelve words of six cells, each with its probability
}
# Ok(())
# }

The example needs no photograph to show the whole round trip. Run bare, it splits the BIP39 test phrase into three shares, draws shares 1 and 3 on worksheet strips, reads both drawings back and recovers the phrase from what it read; given a photograph, it prints every strip it finds:

cargo run --release -p penlock-scan --features bundled-models,print --example scan -- [photo.jpg]

BIP39 word lists

A phrase written out by hand, photographed, turned back into ordered words with the evidence for each: the polygon it was read from, the exact crop both models saw, the literal OCR text, a probability for every one of the 2048 words, the branch that chose, and whether the assembled phrase checksums.

The example app reading a page: the photograph, then boxes as the
            words are found, then a review listing each word with the model's
            confidence beside it

That is the Android example reading the example page included with it, recorded on an emulator — which takes far longer over a page than a phone does, so the reading plays faster than real time and the review after it plays at its own. How it works.

Using the word-list library

bitcoin-vision is both pipelines, each a module and a feature, both on by default: bitcoin_vision::words is the word-list pipeline and bitcoin_vision::penlock the share pipeline. Either is also its own crate — bitcoin-vision-words and penlock-scan — for a dependency on one alone.

This repository contains the required ONNX models. Use the bundled-models feature with a local path or version-pinned Git dependency; there is no runtime download or network service. The repository is https://github.com/penlock-io/bitcoin-vision. The approximately 24.7 MB model set makes a small crates.io package a separate distribution decision, not something achieved by hiding the models at runtime.

The public API inventory tracks the scanner surface separately from the research and worksheet-printing features.

use bitcoin_vision::words::{Scanner, ScanOptions};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let scanner = Scanner::bundled()?; // load once, reuse for multiple images
let photo = std::fs::read("handwritten-words.jpg")?;
let result = scanner.scan(&photo, ScanOptions::default())?;
let document = result.document();

for &index in &document.ordered_observations {
    let word = &document.observations[index];
    // word.selected, word.quad, word.candidates and word.selection
    // Do not log wallet phrases in production.
}
# Ok(())
# }

For externally managed model files, use Scanner::new(ModelBytes { .. }). The five artifact fields are detector, classifier, calibration, recogniser and dictionary. Classifier calibration is checked against the model digest and preprocessing contract. The default build enables ONNX but does not embed model bytes; bundled-models embeds the five files in words/models/, and on the facade it embeds whichever pipelines' models are switched on.

See the result

One real phone capture, followed by the scanner's returned boxes:

Input photograph Actual scanner output
Handwritten two-column test phrase Returned word polygons on the same photograph

The ordered output is shop exhaust erase demand switch powder now fault anchor enact rather cook. This example's checksum passes. It is a demonstration, not an accuracy benchmark. Green means native confirmation was accepted; amber means review is required. Neither color is a ground-truth correctness assertion.

The phrase is a throwaway made for testing. The included full-resolution example photo has no EXIF or device metadata; the README images are reduced for display only, and the scanner reads the full photograph.

Reproduce the example from this repository:

cargo run --release -p bitcoin-vision-words --features bundled-models --example scan -- \
  words/examples/photos/handwritten.png new-output-directory

The example writes a metadata-free canonical photo.png, self-contained overlay.svg with numbered words, display-only preview.png and overlay.png, exact recognition crops/ and structured result.json. It refuses to overwrite an existing output directory. The two PNG previews reproduce the README images. Replace the input path to scan your own image; no private research corpus is needed.

For example, the first observation's selected word is shop, its literal OCR read is shop, and its classifier probability for that word is about 0.9907. The JSON also includes the photo-coordinate quad, all 2048 candidates, selection reason and ordering evidence. Every hybrid_score here is null because the OCR read was itself a BIP39 word, so the exact-text branch took it and ranked nothing; a null score is an unscored candidate, not a zero.

Coordinates and decisions

Polygons refer to the canonical image after EXIF orientation is applied once, and bitcoin_vision_core::decode_image hands back that same frame for display. The final polygon is the crop both models read, with no added padding. Every classifier candidate is retained, probability is a model probability and hybrid_score is not one, and checksum validity never selects a reading order. document() projects decisions already made rather than running anything again; diagnostics: true adds the traces behind them. The pipeline document covers the rest.

Safety and limitations

Where the three shares are kept is the whole security argument. One Penlock share reveals nothing about the seed; any two recover it completely. Two shares in one place is one place holding the seed.

Check a recovered phrase against the paper. Neither a confident reading nor a valid BIP39 checksum establishes correct recognition — a twelve-word phrase carries four checksum bits, so one wrong word still passes about one time in sixteen. Penlock's per-word checksum catches a slip in a single symbol and says so rather than silently correcting it; nothing catches a word the classifier was confidently wrong about except a person reading the paper.

English BIP39 is the only vocabulary here; this is not generic handwriting OCR. Low contrast, ambiguous letters, unusual layouts and missing strokes all cause mistakes.

Photographs, exact crops and JSON results can hold the entire secret, and callers own their storage and deletion. Nothing is logged, nothing is uploaded, and no wallet is created, restored or contacted by these crates.

Models and licensing

Project code and project-authored models are available under either MIT or Apache-2.0, at your choice. The Paddle/RapidOCR models remain Apache-2.0-only; retain their license and attribution when redistributing them. Dependencies, and the fonts in forms/fonts under the SIL Open Font License, retain their own licenses and notices.

Build and test

cargo test --locked --workspace --all-features
cargo test --locked -p bitcoin-vision-words --features bundled-models --example scan
cargo test --locked -p penlock-scan --features bundled-models,print --example scan
cargo check --locked --no-default-features
cargo doc --locked --no-deps --features bundled-models

Tests use synthetic algorithm fixtures and the bundled model artifacts, never the private training or scan corpus. The lockfile records the dependency graph used for verification. A downstream Git dependency uses the downstream lockfile; commit that lockfile to pin its complete graph as well as the crate revision.

This repository remains publish = false: publishing it on GitHub is not a crates.io release. The current lockfile pins chacha20 0.10.2, not the yanked 0.10.1 found in an earlier revision. Git consumers use their own lockfile; pin the dependency graph in the consuming application as well as this revision.

About

Proof-of-concept shares/seedphrase scanning

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages