Skip to content

fix(utils): use UTF-8 byte length for the EIP-191 prefix in hashEthereumMessage - #7328

Merged
ganchoradkov merged 1 commit into
v2.0from
fix/eip191-utf8-byte-length
Sep 23, 2026
Merged

ganchoradkov merged 1 commit into
v2.0from
fix/eip191-utf8-byte-length

Conversation

@ganchoradkov

Copy link
Copy Markdown
Member

Description

hashEthereumMessage built the EIP-191 prefix with message.length, which counts UTF-16 code units. EIP-191 (version 0x45) requires the length of the UTF-8 encoded message. The two are equal for ASCII but differ for any non-ASCII character, so a SIWE/CACAO message with a localized or emoji statement (Sign in to Café ☕, 登录到应用, Let's go 🚀) or a Unicode domain hashed to a value that no compliant signer produces (ethers, viem/ox, MetaMask eth-sig-util, web3.js, geth, Safe all hash over bytes). The result was a deterministic false from both isValidEip191Signature and the non-pre-hashed path of isValidEip1271Signature — i.e. wc_sessionAuthenticate failed for every wallet type against such dApps, on both the wallet and dApp side.

sequenceDiagram
    participant W as Wallet (compliant signer)
    participant S as @walletconnect/utils
    Note over W: bytes = utf8(message)
    W->>W: sign keccak("\x19Ethereum Signed Message:\n" + bytes.length + bytes)
    W->>S: cacao { s: signature }
    rect rgb(255, 235, 235)
    Note over S: before: prefix uses message.length (UTF-16 units)<br/>→ different digest for any non-ASCII char → recovered address ≠ signer → false
    end
    rect rgb(235, 255, 235)
    Note over S: after: prefix uses utf8(message).length<br/>→ identical digest → verification succeeds
    end
Loading

Compatibility

  • ASCII messages hash bit-identically — nothing that verifies today changes.
  • Non-ASCII messages currently fail against every standards-compliant signer, so this can only turn existing false-negatives into passes.
  • This restores the behaviour the SDK had before 45189aa (shipped in 2.18.0), when it delegated to ethers' hashMessage, and matches the other WalletConnect SDKs.
  • hashEthereumMessage is a public export; consumers calling it directly now get the standard EIP-191 hash for non-ASCII input (noted in the changeset).

Independent of #7327 (different region of the same file); verified with git merge-tree that the two branches merge cleanly in either order.

Type of change

  • Chore (non-breaking change that addresses non-functional tasks, maintenance, or code quality improvements)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Draft PR (breaking/non-breaking change which needs more work for having a proper functionality [Mark this PR as ready to review only when completely ready])
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

How has this been tested?

New tests use independent oracles (ox, already a @walletconnect/utils dependency, and a hardcoded ethers v6.17 vector) rather than sharing the implementation:

  • signatures.spec.ts › hashEthereumMessage
    • matches ox PersonalMessage.getSignPayload for ASCII, Latin-1, CJK, emoji (surrogate pair) and an IDN-domain SIWE header;
    • matches a hardcoded ethers v6 hashMessage vector for "Sign in to Café ☕ — 登录 🚀" (25 code units / 36 bytes);
    • isValidEip191Signature accepts a signature over a non-ASCII message produced by a compliant signer (and rejects a tampered message);
    • isValidEip1271Signature (fetch-stubbed) embeds the byte-length digest in the eth_call calldata.
  • cacao.spec.ts: end-to-end validateSignedCacao with a non-ASCII statement and an eip191 signature over formatMessage() output.
  • All 5 new tests fail on the current code and pass with the fix.
  • packages/utils signatures.spec.ts + cacao.spec.ts with a real TEST_PROJECT_ID: 45/45 pass, including the pre-existing ASCII vectors (unchanged hashes).
  • prettier --check, tsc --noEmit -p packages/utils, eslint on touched files: clean, no new findings.

Examples/Screenshots (Optional)

Signature from a compliant signer (ox) over each message, verified with isValidEip191Signature:

Message Before After
ASCII statement ✅ true ✅ true (identical hash)
Sign in to Café ☕ ❌ false ✅ true
登录到应用 ❌ false ✅ true
Let's go 🚀 ❌ false ✅ true
münchen.de wants you to sign in… ❌ false ✅ true

Checklist

  • I have performed a self-review of my own code
  • My changes generate no new warnings
  • Any dependent changes have been merged and published in downstream modules (n/a — self-contained change in @walletconnect/utils)

Additional Information (Optional)

Found while triaging the 0x-domain EIP-1271 report fixed in #7327. Changeset included: @walletconnect/utils patch.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 27, 2026 08:57
@claude

claude Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Claude finished @ganchoradkov's task in 1m 48s —— View job


Code Review

  • Read changed files
  • Review implementation
  • Check tests
  • Subagent analysis (breaking changes)

The fix is correct and well-tested. One finding from the breaking-changes analysis:

Found 1 issue(s)

Issue 1: Changeset version level may be too low for an observable public-API output change

ID: brk-signatures-hashEthereumMessage-bytelength-change-c3f2
File: .changeset/eip191-utf8-byte-length.md:2
Severity: MEDIUM
Category: breaking_change

Context:

  • Pattern: hashEthereumMessage is a public export of @walletconnect/utils. Its return value changes for any non-ASCII input. The changeset marks this as patch.
  • Risk: Any consumer that calls hashEthereumMessage directly and stores, logs, or compares the resulting hash (e.g. a cached "already verified" state) will observe a different value after upgrading. Cross-version comparisons diverge.
  • Impact: Narrow in practice — the old hash was non-EIP-191-compliant and no wallet would ever produce a signature over it — but the output of a named public function changes, which is conventionally a minor bump.
  • Trigger: Consumer stores an EIP-191 hash computed pre-upgrade for a non-ASCII message and then compares it against a freshly-computed one post-upgrade.

Recommendation: Consider bumping the changeset to minor and adding an explicit migration note that persisted hashes for non-ASCII messages must be recomputed. The new output matches ethers.hashMessage / ox PersonalMessage.getSignPayload — citing this gives consumers a clear cross-reference.

"@walletconnect/utils": minor

Implementation (packages/utils/src/signatures.ts:28-32): Correct. Encoding message bytes first then measuring messageBytes.length is exactly what EIP-191 requires. The concat([prefixBytes, messageBytes]) approach is clean and avoids any ambiguity.

Tests: Good use of independent oracles (ox and a hardcoded ethers v6 vector). The EIP-1271 calldata assertion at data.slice(10, 74) correctly targets the 64 hex-char hash field after the 0x1626ba7e selector. finally { fetchSpy.mockRestore() } cleanup is correct.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes @walletconnect/utils EIP-191 hashing for non-ASCII messages by switching the prefix length from JavaScript UTF-16 code units to UTF-8 byte length, ensuring signature verification matches standards-compliant signers and restoring correct wc_sessionAuthenticate behavior for SIWE/CACAO messages containing Unicode.

Changes:

  • Update hashEthereumMessage to prefix the UTF-8 byte length and hash prefixBytes || messageBytes.
  • Add regression tests (with independent hashing/signing oracles via ox, plus a hardcoded ethers v6 vector) covering non-ASCII hashing and both EIP-191/EIP-1271 verification paths.
  • Add a changeset documenting the behavioral fix and compatibility notes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
packages/utils/src/signatures.ts Fix EIP-191 prefix length calculation by using UTF-8 byte length and hashing concatenated byte arrays.
packages/utils/test/signatures.spec.ts Add focused tests validating hashEthereumMessage against independent oracles and ensuring non-ASCII signatures verify correctly.
packages/utils/test/cacao.spec.ts Add end-to-end CACAO validation regression test for a non-ASCII statement under eip191.
.changeset/eip191-utf8-byte-length.md Document the patch-level behavior change and why it’s a correctness fix with ASCII unchanged.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@@ -0,0 +1,5 @@
---
"@walletconnect/utils": patch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: Changeset version level may be too low for an observable public-API output change

Severity: MEDIUM
Category: breaking_change
Tool: Claude Auto Review

Context:

  • Pattern: hashEthereumMessage is a public export of @walletconnect/utils. Its return value changes for any non-ASCII input. The changeset marks this as patch.
  • Risk: Any consumer that calls hashEthereumMessage directly and stores, logs, or compares the resulting hash (e.g. a cached "already verified" state) will observe a different value after upgrading. Cross-version comparisons diverge.
  • Impact: Narrow in practice — the old hash was non-EIP-191-compliant and no wallet would ever produce a signature over it — but the output of a named public function changes, which is conventionally a minor bump.
  • Trigger: Consumer stores an EIP-191 hash computed pre-upgrade for a non-ASCII message and then compares it against a freshly-computed one post-upgrade.

Recommendation: Consider bumping the changeset to minor and adding an explicit migration note that persisted hashes for non-ASCII messages must be recomputed. The new output matches ethers.hashMessage / ox PersonalMessage.getSignPayload — citing this gives consumers a clear cross-reference.

"@walletconnect/utils": minor

…eumMessage

`hashEthereumMessage` prefixed `message.length` — UTF-16 code units — but
EIP-191 (version 0x45) requires the length of the UTF-8 encoded message.
The two differ for any non-ASCII character, so a SIWE/CACAO message with a
localized or emoji statement, or a Unicode domain, hashed to a value that no
compliant signer (ethers, viem/ox, MetaMask, geth, Safe…) produces, and both
`eip191` and `eip1271` verification deterministically failed for every
wallet type.

Hashes of ASCII messages are unchanged. This restores the behaviour the SDK
had before 45189aa (2.18.0), when it delegated to ethers' `hashMessage`.

Tests use `ox` and a hardcoded ethers v6 vector as independent oracles and
cover eip191 recovery, eip1271 calldata, and an end-to-end
`validateSignedCacao` with a non-ASCII statement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ganchoradkov
ganchoradkov force-pushed the fix/eip191-utf8-byte-length branch from b02aa41 to c247899 Compare September 23, 2026 10:01
@ganchoradkov
ganchoradkov merged commit bd0adde into v2.0 Sep 23, 2026
16 checks passed
@ganchoradkov
ganchoradkov deleted the fix/eip191-utf8-byte-length branch September 23, 2026 10:11
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants