๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

Your Signature String Depends on Your Dependency Version

September 28, 2026

A server you're calling validates a request header with something like this:

re.fullmatch(r"0x[0-9a-fA-F]{130}", signature_header_value)

That is the shape of an EIP-712 signature as a hex string: 0x plus 130 hex characters (65 bytes). Nothing exotic. The client-side code that produces it is one line. And whether that line satisfies the regex depends on which version of a library you happen to have installed โ€” not on the line, and not on anything the line does.

Here is the same expression, run against five versions of hexbytes:

from hexbytes import HexBytes
h = HexBytes("0x" + "ab" * 65)   # a 65-byte signature
sig = h.hex()
hexbytesreleasedh.hex() lengthstarts with 0x.to_0x_hex() exists
0.3.12023-06-08132yesno
1.0.02023-11-07130nono
1.1.02024-03-01130nono
1.2.02024-03-19130noyes
1.3.12025-05-14130noyes

I ran each one; the commands are at the bottom so you can reproduce the table rather than take it from me.

What changed, and how quietly

HexBytes subclasses bytes. Python's bytes.hex() returns bare hex โ€” it has never returned a 0x prefix, and it never will. HexBytes 0.3.x chose to override .hex() to be 0x-prefixed, so it did not behave like the type it subclasses.

1.0.0 removed that override.

An explicit override was deleted, and the class fell back to the inherited method. From the caller's side there is no signal at all. No exception, no warning, no DeprecationWarning you could have silenced. .hex() still returns a str. It still returns the right bytes. It returns them in a different shape.

This is the uncomfortable part. Most breaking changes announce themselves by breaking execution โ€” an ImportError, a TypeError, a missing attribute. You find those in the first minute. This one breaks a value, and values only get checked where they are consumed, which is usually a different process, often on a different machine, frequently owned by someone else. Your code keeps running. Their regex returns None.

The fix arrived later than the break

to_0x_hex() โ€” the accessor that returns the prefixed form โ€” first appears in 1.2.0.

That means for two releases, 1.0.0 and 1.1.0, there was no method on HexBytes that returned a 0x-prefixed hex string at all. If you were on 1.1.0 and wanted the prefix, string concatenation was the only thing available. Which is exactly how the wrong fix gets written.

The fix that is wrong the other way

sig = "0x" + h.hex()      # do not ship this

On 1.x this is correct. On 0.3.x โ€” a version still pinned in plenty of lockfiles โ€” it produces 0x0xababโ€ฆ, 134 characters, and the regex above rejects it for being too long. The failure message from a strict server is "malformed signature", which points at the signer, not at the concatenation.

So you have two populations of machines and one line of code that cannot be right on both:

"0x" + h.hex()normalized
hexbytes 0.3.1134 chars โ€” rejected132 chars โ€” accepted
hexbytes 1.1.0132 chars โ€” accepted132 chars โ€” accepted
hexbytes 1.2.0132 chars โ€” accepted132 chars โ€” accepted

The fix that is right on both

Prefix normalization instead of prefix concatenation:

from hexbytes import HexBytes

def hex_signature(raw) -> str:
    """Return `0x` + 130 hex chars, whatever the installed hexbytes returns."""
    h = HexBytes(raw)
    s = h.to_0x_hex() if hasattr(h, "to_0x_hex") else h.hex()
    return s if s.startswith("0x") else "0x" + s

The hasattr branch is optional politeness โ€” h.hex() followed by the prefix check is already correct on every version in the table, because on 0.3.x the check sees the existing 0x and leaves the string alone. Prefer the shorter form if you like:

sig = hx.hex()
sig = sig if sig.startswith("0x") else "0x" + sig

The property that matters is that this does not need to know which version it is running on. Any fix that requires knowing the version is a fix that will be wrong on the machine you didn't test โ€” and "the machine you didn't test" is precisely the situation where a shape mismatch is invisible until a third party rejects your request.

Check your own environment in one line

Before trusting any signing snippet you copied from anywhere, including from a post like this one:

python3 - <<'PY'
from hexbytes import HexBytes
h = HexBytes("0x" + "ab" * 65)
print(len(h.hex()), h.hex().startswith("0x"), hasattr(h, "to_0x_hex"))
PY

132 True ... means you are on a 0.3.x-style accessor. 130 False False means you are on 1.0.0 or 1.1.0 โ€” the two releases with no prefixed accessor available. 130 False True means you are on โ‰ฅ1.2.0. All three are legitimate installs; only one of them will make "0x" + h.hex() work.

If you need to pin something, pin on behavior you measured, not on a version number someone quoted in a comment. Library version ranges move; the shape your server validates against does not.

The generalizable part

This is one instance of a shape worth naming: a value whose format is a property of the environment rather than of the code that computes it.

It shows up whenever a library wraps a builtin and then changes how closely it imitates it. HexBytes is bytes plus conveniences โ€” and one of those conveniences was later withdrawn, leaving a class that still looks like the thing whose semantics changed. The call site is unchanged; the contract underneath it is not.

The detection strategy that generalizes is not "read the changelog" (it said nothing; there was no note). It is: assert the shape of the value you put on the wire, at the boundary where you put it there. Not assert sig is not None โ€” assert the thing the far end asserts:

import re
assert re.fullmatch(r"0x[0-9a-fA-F]{130}", sig), f"bad signature shape: {len(sig)} chars"

That assertion fails in your process, with your stack trace, on the machine that has the odd version. It is worth more than any amount of care taken in choosing the version.


Reproducing the table

python3 -m venv /tmp/hx && . /tmp/hx/bin/activate
for v in 0.3.1 1.0.0 1.1.0 1.2.0 1.3.1; do
  pip install -q "hexbytes==$v"
  python3 -c "
from hexbytes import HexBytes
h = HexBytes('0x' + 'ab' * 65)
print('$v', len(h.hex()), h.hex().startswith('0x'), hasattr(h, 'to_0x_hex'))"
done

I ran this against the published wheels from PyPI rather than reading the source, because the claim is about behavior. hexbytes 2.0.0 (2026-08-21) still exposes to_0x_hex() and no longer overrides hex() at all โ€” read from its hexbytes/main.py, since it requires Python โ‰ฅ3.10 and the machine I checked on is 3.9.