Build1 publisher3 min readPublished
HexBytes 1.0 strips the 0x prefix that EIP-712 signature validators expect
HexBytes 1.0 dropped the 0x prefix from .hex(), so one signing line returns 130 or 132 characters depending on the installed version. The fix that survives every version is a client-side prefix check, because the first code to notice is usually someone else's regex.
The Engineer · Build desk

What happened
- The 1.0.0 change raises no exception or warning, and .hex() still returns a str holding the correct bytes in a different shape.
- The prefixed accessor to_0x_hex() arrived only in 1.2.0, leaving 1.0.0 and 1.1.0 with no method that returned a 0x string.
- On 0.3.x, the common patch "0x" + h.hex() yields a 134-character 0x0x string that a strict server rejects as a malformed signature.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure The breakage lands on a server another team owns, where a 'malformed signature' reply points debugging at the signing code.
- constraint No version-keyed patch is safe across a mixed fleet, because 0.3.x and 1.x need opposite prefix handling for the same line.
- decision Pinning helps only when the pin is backed by a measured check of the output shape the server enforces.
- precedent Any library that wraps a builtin can break callers the same way by moving closer to the parent type's behavior.
On 0.3.x, calling `h.hex()` on a 65-byte signature returns 0x plus 130 hex characters, 132 characters in all, because HexBytes overrode the method to add the prefix [3][1]. On 1.0.0 the override is gone. The call falls through to Python's `bytes.hex()`, which has never emitted a prefix [2][4]. The bytes are identical. A server checking `re.fullmatch(r"0x[0-9a-fA-F]{130}", value)` accepts only the first shape [1]. The dev.to post that documents this ran the same expression against four hexbytes versions and published the commands so readers can reproduce the table [16].
Most breaking changes stop execution with an ImportError or a TypeError. This one changes a value and raises no exception or warning of any kind, DeprecationWarning included [5]. The author wrote that values "only get checked where they are consumed, which is usually a different process, often on a different machine, frequently owned by someone else" [6]. A strict server replies "malformed signature," and that message sends debugging toward the signer [9].
The release sequence made the wrong fix easy to write. The prefixed accessor, `to_0x_hex()`, first appears in 1.2.0 [7]. On 1.0.0 and 1.1.0, string concatenation was the only way to get a 0x string out of the library [8]. Run `"0x" + h.hex()` on a machine still at 0.3.x and it produces 0x0x followed by the hex, 134 characters [9]. The regex rejects it as too long [9]. According to the author, 0.3.x is still pinned in plenty of lockfiles [10]. The post's summary is two populations of machines and one line of code that cannot be right on both [17].
The fix in the post is good engineering. It calls `h.hex()` and prepends 0x only when the string does not already start with it [11]. On 0.3.x the check sees the existing prefix and leaves the string alone, so the same two lines are correct on every version tested [11]. The code never asks which version is installed. "Any fix that requires knowing the version is a fix that will be wrong on the machine you didn't test," the author wrote [12].
On pinning, the evidence supports something narrower than a general case for locking versions. A pin fixes one lockfile. The failure described here comes from separate machines that resolved separate versions [10][17]. The author's advice is to "pin on behavior you measured, not on a version number someone quoted in a comment" [14]. The post's probe prints the output length, whether it starts with 0x, and whether `to_0x_hex` exists: `132 True` marks 0.3.x behavior, `130 False False` marks 1.0.0 or 1.1.0, and `130 False True` marks 1.2.0 or later [13]. In my context I would run that probe in CI, next to an assertion against the exact pattern the server enforces. A dependency bump then fails a test before it fails a partner's request.
The post does not quote the hexbytes release notes, so this source does not establish whether the 1.0.0 changelog flagged the change. The author places the bug in a wider class: libraries that wrap a builtin and then change how closely they imitate it [15]. HexBytes 1.0.0 made the subclass behave more like `bytes`, and that was the breaking change [2][4].
What to watch
- Whether hexbytes release notes or a later release document the .hex() shape change for users upgrading from 0.3.x.
- Whether signing libraries built on hexbytes normalize the prefix internally or pass raw .hex() output to callers.
- Whether servers that validate EIP-712 headers start accepting bare 130-character hex alongside the 0x form.