hh turns a blockchain address, a public key or any hash into a small deterministic picture that a person can compare at a glance: a 4 x 4 matrix of solid squares, circles and triangles in four colours. It exists to catch address poisoning and clipboard substitution, which work because people check only the first and last characters of a long string. The colours are chosen so that people with a colour vision deficiency can tell them apart as well.
0x1234567890abcdef00112233445566778899aabb |
0x12345678f1e2d3c4b5a69788796a5b4c8899aabb |
|---|---|
![]() |
![]() |
The two addresses agree in their first and last eight hex digits. Their pictures are unrelated.
This is the Python implementation, hh-python, published on PyPI as humanized-hash. It is pure
Python, depends on the standard library only, supports CPython 3.9 to 3.14 and PyPy 3.10, and
produces, byte for byte, the output of the C++ reference implementation
hh-cpp, which owns the
specification and the golden vectors.
testdata/ is a byte-identical copy of those vectors; testdata/SOURCE names the hh-cpp release
they came from.
Every implementation produces the same pictures, tags and encoded files, byte for byte, and its tests check it against a copy of the golden vectors of hh-cpp.
| Language | Repository | Package | Install |
|---|---|---|---|
| C++17, C ABI | hh-cpp, the reference: specification and golden vectors | CMake hh::hh, pkg-config hh (releases) |
CMake FetchContent or find_package(hh) |
| Kotlin and Java: JVM, Android | hh-kotlin | Maven Central io.github.censync:hh |
implementation("io.github.censync:hh:1.0.0") |
| TypeScript and JavaScript: browsers, Node.js, Deno, Bun | hh-ts | npm @censync/hh |
npm install @censync/hh |
| Go | go-hh | github.com/censync/go-hh |
go get github.com/censync/go-hh |
| Python | hh-python (this repository) | PyPI humanized-hash |
pip install humanized-hash |
A Sui address has 64 hex digits, and nobody reads 64 digits. The second address below differs from the first in one digit, the third in two; the changed digits are marked. In the text they are easy to miss. The pictures and the tags are unrelated, because every cell depends on every bit of the input.
What a forger pays, by calculation. One current GPU tries about 1.4 billion addresses per second; a try against hh also has to compute the stretched base digest, which leaves about 680 000 tries per second. The figures are the expected search times on one such GPU for a typical picture (SECURITY.md of hh-cpp has the reasoning).
| The forged address has to match | Tries | One GPU |
|---|---|---|
| the first 4 and the last 4 hex digits | 2^32 | 3 seconds |
| the first 6 and the last 6 hex digits | 2^48 | 2.3 days |
| the first 8 and the last 8 hex digits | 2^64 | 420 years |
| the universal picture, with two cells allowed to differ | 2^52, stretched | 210 years |
| the universal picture, in every cell | 2^68, stretched | 14 million years |
| the ends of the text and the picture | the product of the two | |
| the keyed picture | cannot be searched: without the key the picture cannot be computed |
A lookalike of the text is cheap, which is why address poisoning works. A lookalike of the picture is not, and the two costs multiply. A picture that looks the same is still strong evidence rather than proof; the tag or the full address is the check that is certain.
- Two modes. A universal picture is the same for everyone and is what two people compare. A keyed picture is computed with a 32-byte secret of the wallet: an attacker who does not hold the key cannot compute, and therefore cannot grind, a lookalike. Inside an application keyed pictures are the default.
- Deterministic to the byte. Integer arithmetic only. The same input gives the same pixels and the same PNG, BMP and JPEG bytes as hh-cpp, on every platform and every interpreter.
- Frozen. The algorithm has no version and never changes; a picture that a user has learned stays the same for ever. Library releases follow SemVer and never alter the output.
- No dependencies.
dependencies = []: no Pillow, no NumPy, not evenzlib. SHA-256, HMAC and PBKDF2 come fromhashlibandhmac; deflate, PNG, BMP, the baseline JPEG encoder and the rasteriser are part of the library. - Fast enough for an interpreter. The rasteriser works on whole rows of samples with integer bit sets instead of looping over samples: on a desktop core a 128-pixel picture takes one to two milliseconds, its PNG three to four, the base digest two to four.
- Made for colour vision deficiency. About one man in twelve does not see colours the way the rest do. The four colours were chosen for them: the palette was searched so that every pair stays apart under simulated protanopia, deuteranopia and tritanopia, and every colour keeps a contrast of 3:1 on white and on dark surfaces. Shape carries most of the information, so a picture still works in greyscale (the measurements are in docs/design of hh-cpp).
- Pixels, not pictures. The library returns RGBA bytes and encoded files; making a Tkinter, Qt or Pillow image of them is one line in the host.
- Typed. Type hints throughout and a
py.typedmarker.
hh answers one question: is this the same address as the one I mean? Wherever a person has to answer it from a long string, a picture answers it faster and more reliably than the first and last characters do.
- Sending and confirming. The picture of the recipient stands next to the address field and on the confirmation screen. A swapped or mistyped address changes it completely.
- Address books and account lists. Every saved payee and every account of the user carries its picture, so a list is scanned instead of read; 32 to 48 px is enough for recognition.
- Two devices of one user. An offline signer and the online device show the same picture for the same address, so the two screens are compared at a glance instead of 64 characters.
- Support, screenshots and voice. The six-character tag (
TKS-PVH) travels through chat and over the phone; the picture travels in a screenshot. - Documents and messages from a server. The encoders return PNG, BMP or JPEG bytes, so a backend puts the picture into a receipt, an invoice or an email without a graphics library.
- Anything that is a hash, not only an address. An SSH or PGP key fingerprint, a TLS certificate pin, an API key, the checksum of a backup or of a firmware image.
Three rules keep it honest: the picture complements the text check and never replaces it; inside one application keyed pictures are the default and universal pictures are what is shared with others; a picture that backs a decision is at least 64 dp and stands beside the picture it is compared with. docs/INTEGRATION.md has the rest.
pip install humanized-hashfrom humanized_hash import BaseDigest, Fingerprint
digest = BaseDigest.of_hex("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed") # slow: cache it
fingerprint = Fingerprint.universal(digest) # or Fingerprint.keyed(digest, key)
image = fingerprint.render(128) # 128 x 128 RGBA pixels, image.rgba
png: bytes = image.encode_png() # or image.save("address.png")
tag: str = fingerprint.tag # "TKSPVH", shown as TKS-PVHA keyed picture needs the 32-byte secret of the wallet:
from humanized_hash import SecretKey
with SecretKey(key_bytes) as key: # wiped when the block ends
private = Fingerprint.keyed(digest, key)BaseDigest.of takes the bytes of an address, of_hex their hexadecimal spelling and of_text an
address that exists only as text. BaseDigest.from_bytes computes nothing: it restores a digest
that was cached as bytes(digest).
Invalid values raise HhError, a ValueError whose code is the error of the specification;
arguments of the wrong type raise TypeError.
A decision (confirming a payment, verifying a pasted address) should be backed by a picture of at least 64 device-independent pixels, better 96, next to the picture it is compared with. Smaller pictures are for recognition in lists. See docs/INTEGRATION.md for web backends, Tkinter, Pillow and Qt, caching and key handling, and SECURITY.md of hh-cpp for what a picture proves and what it does not.
The cells, the palette and the geometry are fixed; the host chooses the shape, the background and, for a keyed picture, the marker. Every picture below is the address of the quick start, rendered at 128 px.
| Shape | Opaque white | Light blue E8EEF7 |
Transparent | Keyed, transparent |
|---|---|---|---|---|
| Square | ![]() |
![]() |
![]() |
![]() |
| Round | ![]() |
![]() |
![]() |
![]() |
- Background. Any colour with any transparency. Outside rounded corners and outside the disc the picture is transparent anyway, so a transparent background takes whatever is behind it: the two transparent columns above are the same bytes on a light page and on a dark one.
- Contrast. An opaque background is refused below 2:1 against a palette colour, and the
contrast report gives the WCAG ratio so that a host can warn below 3:1. White scores 300, the
light blue above 257,
121212scores 300; mid greys and saturated surfaces are what to avoid. - The frame marks the mode. A universal picture has no frame by default, a keyed square gets rounded corners, and the round shape stays unmarked unless a style is asked for (ticks above). Pick one style and keep it everywhere in an application.
- The round shape inscribes the same grid in a circle, so its cells are about a third smaller; give it a third more pixels.
options = RenderOptions(
shape=Shape.ROUND,
background=0xE8EEF7,
background_alpha=0, # transparent; the colour then does not matter
frame=FrameStyle.TICKS, # a marker: keyed pictures only
)
report = options.measure_contrast(0xFFFFFF)
if report.figures_x100 < 300:
... # warnA command line program that writes the picture of an address to a PNG file and prints its tag.
mkdir hh-example && cd hh-example
python3 -m venv .venv && . .venv/bin/activate
pip install humanized-hashmain.py:
from humanized_hash import BaseDigest, Fingerprint
digest = BaseDigest.of_hex("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed")
fingerprint = Fingerprint.universal(digest)
fingerprint.render(128).save("address.png")
tag = fingerprint.tag
print(f"{tag[:3]}-{tag[3:]}")python main.py prints TKS-PVH and writes address.png, byte for byte the file
testdata/golden/evm-1-universal-128.png that every implementation reproduces.
The package installs the command humanized-hash; python -m humanized_hash is the same tool.
humanized-hash 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed --out address.png
humanized-hash --text bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 --size 256 --out address.png
humanized-hash <hex> --key <64 hex digits> --shape round --frame double --out private.pngIt takes the options of hh_cli of hh-cpp and prints the same values. It is a demonstration and
a test tool: a real host never takes a key from the command line.
Python 3.9 or newer; nothing to install.
python -m unittest discover -s tests -t . # every test, against the source tree
tools/crosscheck.sh <path to hh_cli of hh-cpp> # differential test against hh-cpp
python tools/bench.py # what each step costs on this machine
python -m build # sdist and wheel (needs the build package)The tests reproduce every record of the golden vectors, compare the rasteriser with a per-sample transcription of the specification and decode every encoder's output. The rules for patches are in CONTRIBUTING.md, the releases in CHANGELOG.md.
MIT, see LICENSE. Copyright (c) 2026 Dmitry Mandrika. CenSync












