Skip to content

Add wallet Sign Message flow for CEX ownership verification - #6065

Open
j0ntz wants to merge 6 commits into
developfrom
jon/sign-message-cex
Open

Add wallet Sign Message flow for CEX ownership verification#6065
j0ntz wants to merge 6 commits into
developfrom
jon/sign-message-cex

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

EdgeApp/edge-currency-plugins#454 — the plugin owns the signature header-byte encoding and reads the signatureFormat option this PR sends. Against a published plugin without #454 the unknown key is dropped by the cleaner, so BIP-137 silently falls back to a legacy header. The two ship together; the dep bump lands once #454 publishes.

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

Description

Adds an in-app Sign Message flow so a user can prove ownership of a self-hosted wallet by signing an exchange-provided message. This addresses EU Travel Rule / TFR withdrawal checks where CEXs/CASPs (Kraken, Coinbase, Bitvavo, Bitpanda, OKX, Bringin, NiceHash, etc.) require a signed message from the destination wallet instead of a manual ID/selfie proof.

BTC-family (UTXO), per the task scope.

What it does

  • Adds a Sign Message option to the wallet list menu for Bitcoin-family (UTXO) wallets.
  • Opens a new SignMessageScene with an editable signing address, a multiline message input, and a Sign Message button. The signature renders in a copyable card.
  • The signing address defaults to the wallet's receive address but can be replaced with the specific (often previously-used) address an exchange asked the user to prove. A Use default address link restores the default, and the wallet must control whichever address is entered.
  • On SegWit-capable chains, a Signature Format selector offers Standard (Electrum) or BIP-137. Some exchanges (Bringin) reject a SegWit address whose message carries a legacy header byte; others expect exactly the legacy one.
  • Includes a short phishing-safety note ("only sign messages from a service you trust; a signature never reveals your private keys").

Implementation notes

  • signMessage is used rather than signBytes because for Bitcoin signBytes base64-re-encodes the bytes before signing, which would produce a signature over the wrong data. signMessage signs the literal UTF-8 message the exchange verifies (same pattern already used by bityProvider and EdgeProviderServer).
  • The plugin owns the header-byte encoding. The GUI passes signatureFormat: 'electrum' | 'bip137' through otherParams and does not rewrite the signature. Fix currency symbol #454 derives the script type from the address' own derivation path, so nothing here has to infer an address encoding from the address string.
  • The format selector's visibility comes from SPECIAL_CURRENCY_INFO.hasSegwit, which was completed for the 6 chains it was missing relative to the plugin's authoritative coinInfo.segwit. Note this flag also drives PaymentProto's 1.8x required-fee-rate buffer, so those chains now get that buffer too.
  • The friendly "wallet does not control that address" message keys off the plugin's named AddressNotOwnedError rather than matching error message text.

Files

  • SignMessageScene.tsx (new scene)
  • routerTypes.tsx, Main.tsx (route + scene registration)
  • WalletListMenuActions.tsx, WalletListMenuModal.tsx (menu entry, UTXO-scoped)
  • WalletAndCurrencyConstants.ts (hasSegwit documented and completed)
  • en_US.ts (strings)

Asana: https://app.asana.com/0/1215088146871429/1209296431612665

Testing

Driven end-to-end on the iOS simulator (edge-funds, My Bitcoin) against a build with #454 linked in via updot, signing with the wallet's own bip84 receive address bc1q7f5wkp5k0847utsec7p8v59cqj7et6wrzz7dv4:

Format Signature Header byte
Standard (Electrum) INoC+bq5tWf/…azdA1tk= 32 (31 + recid 1)
BIP-137 KNoC+bq5tWf/…azdA1tk= 40 (39 + recid 1, native SegWit P2WPKH)

Identical r/s, header byte only. Both were recovered and verified against that address with bitcoinMessage.verify(..., checkSegwitAlways: true). Proof screenshots attached below. Jest suite passes (92 suites / 557 tests / 107 snapshots), tsc --noEmit clean.

Earlier rounds, also on the iOS sim: entering an address the wallet does not own surfaces the friendly error, and the editable-address / default-restore behavior was verified by David Coen on a cheese build.


Note

Medium Risk
The flow invokes wallet signing with user-supplied messages and addresses, so correctness and phishing UX matter, but keys are not exported and scope is limited to existing UTXO signMessage plugins.

Overview
Adds a Sign Message entry to the wallet list menu for supported Bitcoin-family (UTXO) plugins, wired through navigation to a new SignMessageScene.

The scene lets users paste an exchange-provided message, choose an editable signing address (defaulting to the wallet’s receive address, with optional Use default address), and on SegWit-capable chains pick Standard (Electrum) vs BIP-137 signature encoding. Signing calls wallet.signMessage with publicAddress and signatureFormat; the copyable signature is cleared when address, message, or format changes, and AddressNotOwnedError is mapped to a friendly error.

Currency metadata gains a documented hasSegwit flag and updates several UTXO coins so the format selector only appears where SegWit applies. CHANGELOG and locale strings document the feature.

Reviewed by Cursor Bugbot for commit 217c535. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (iOS sim)

agent proof 1209296431612665 01 wallet menu sign message

agent proof 1209296431612665 01 wallet menu sign message

agent proof 1209296431612665 02 sign message scene

agent proof 1209296431612665 02 sign message scene

agent proof 1209296431612665 03 signature success

agent proof 1209296431612665 03 signature success

Captured by the agent's in-app test run (build-and-test).

Comment thread src/components/scenes/SignMessageScene.tsx
Comment thread src/components/scenes/SignMessageScene.tsx
Comment thread src/locales/en_US.ts Outdated
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 97138ed to 7624da7 Compare July 1, 2026 22:18
@j0ntz

j0ntz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after bugbot fixes)

agent proof 1209296431612665 04 segwit signature success

agent proof 1209296431612665 04 segwit signature success

agent proof 1209296431612665 05 signature cleared on edit

agent proof 1209296431612665 05 signature cleared on edit

Captured by the agent's in-app test run (build-and-test).

Comment thread src/components/scenes/SignMessageScene.tsx
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 7624da7 to d59ad8d Compare July 1, 2026 22:23
@j0ntz

j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: user-selectable signing address, Ravencoin UTXO wallet on iOS sim)

agent proof 1209296431612665 01 signmsg default address

agent proof 1209296431612665 01 signmsg default address

agent proof 1209296431612665 02 signature default address

agent proof 1209296431612665 02 signature default address

agent proof 1209296431612665 03 address edited signature cleared

agent proof 1209296431612665 03 address edited signature cleared

agent proof 1209296431612665 04 signature after reset

agent proof 1209296431612665 04 signature after reset

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: BIP-137 signature format

agent proof 1209296431612665 01 format selector default

agent proof 1209296431612665 01 format selector default

agent proof 1209296431612665 02 standard signature

agent proof 1209296431612665 02 standard signature

agent proof 1209296431612665 03 bip137 signature

agent proof 1209296431612665 03 bip137 signature

agent proof 1209296431612665 04 non segwit hidden

agent proof 1209296431612665 04 non segwit hidden

Captured by the agent's in-app test run (build-and-test).

Comment thread src/util/bitcoinMessageSignature.ts Outdated
Comment thread src/components/scenes/SignMessageScene.tsx
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from bb08a13 to 8870e5a Compare July 9, 2026 22:36

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8870e5a. Configure here.

Comment thread src/util/bitcoinMessageSignature.ts Outdated
@j0ntz

j0ntz commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after Bugbot fixes): BIP-137 on HEAD + Taproot guard

agent proof 1209296431612665 01 format selector default

agent proof 1209296431612665 01 format selector default

agent proof 1209296431612665 03 bip137 signature

agent proof 1209296431612665 03 bip137 signature

agent proof 1209296431612665 04 non segwit hidden

agent proof 1209296431612665 04 non segwit hidden

agent proof 1209296431612665 05 taproot rejected

agent proof 1209296431612665 05 taproot rejected

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch 2 times, most recently from 4b7a06e to 752e288 Compare July 9, 2026 22:51
j0ntz added 2 commits July 14, 2026 12:57
Adds a Sign Message option to the wallet list menu for Bitcoin-family
(UTXO) wallets. The new scene shows the wallet's receive address, lets
the user paste an exchange-provided message, signs it with the wallet
key, and returns a copyable signature. This lets users prove control of
a self-hosted wallet for CEX/CASP withdrawal checks (EU Travel Rule)
without manual ID/selfie verification.
Add signMessageInput and signMessageButton testIDs to the Sign Message
scene so UI automation can drive the message field and sign action by a
stable selector.
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 752e288 to b0e8f18 Compare July 14, 2026 19:58

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@peachbits

Copy link
Copy Markdown
Contributor

Nice flow, and the phishing-safety copy is a good call. My feedback is mostly architectural — where this logic lives rather than whether it works. But first, a blocking issue that isn't visible from this PR alone.

This PR and edge-currency-plugins#454 silently break each other

bitcoinMessageSignature.ts is built on the assumption that "the plugin emits the legacy Electrum format" (stated in the header comment and again in the sign handler). EdgeApp/edge-currency-plugins#454 makes that assumption false — it threads path.format into bitcoinjs-message so the plugin emits BIP-137 headers natively.

Running this PR's applyBip137Header verbatim against both plugin versions:

plugin header GUI "Standard" GUI "BIP-137"
today 31 31 ✅ 39 ✅
after #454 39 39 ❌ 39 ✅

applyBip137Header computes recoveryId = bytes[0] - LEGACY_COMPRESSED_HEADER_BASE. On a post-#454 header that's 39 - 31 = 8, which trips the recoveryId > 3 guard and returns the signature unchanged. BIP-137 stays correct by accident, but "Standard (Electrum)" silently emits a BIP-137 signature — and since the scene defaults to the segwitAddress (bip84), that's the default path. There would then be no way to produce a standard signature at all.

Nothing catches this: the two PRs are in different repos, and the unit tests here only exercise the helper against synthetic legacy headers. Whichever lands second breaks the other.

The segwit map

SEGWIT_SIGN_CHAINS should live in SPECIAL_CURRENCY_INFO — but there's a bigger problem than placement. Segwit knowledge now sits in three places that disagree, while the authoritative answer is in a fourth:

  • SEGWIT_SIGN_CHAINS (new, in the util) — 3 chains
  • SPECIAL_CURRENCY_INFO.hasSegwit4 true, 1 explicit false. This already exists, and notably lacks digibyte, which the new map needs.
  • the menu's pluginIds array — 22 hardcoded ids, including badcoin (a test coin)
  • coinInfo.segwit in edge-currency-plugins — 11: bitcoin (+2 testnets), litecoin, digibyte, bitcoingold (+testnet), groestlcoin, feathercoin, ufo, vertcoin

So the new map is missing 8 segwit chains. Moving it to SpecialCurrencyInfo is the right near-term fix since that's the established pattern, but it still leaves a hand-maintained copy of something the plugin knows for certain.

The nestedPrefixes entries have the same problem in miniature. DigiByte is declared as { nestedPrefixes: ['S'] }, but the plugin has scriptHash: [0x3f, 0x05] — two prefixes, yielding both S… and 3… P2SH addresses. A DigiByte 3… address falls through to 'legacy' in getBip137AddressKind, so it gets no header remap and produces a signature a BIP-137 verifier rejects. That's the failure mode of deriving address encodings by hand in the GUI: the plugin has the prefix bytes, so it can't drift.

Header rewriting in the GUI

Beyond being awkward in a generic surface, this is a reimplementation of what #454 puts in the plugin — and that duplication is exactly what causes the conflict above.

getBip137AddressKind also classifies addresses by leading-character and string-length heuristics (P2WPKH_CHARS_AFTER_PREFIX, nestedPrefixes), re-deriving what the plugin already does properly via addressToScriptPubkey. The GUI shouldn't need to know that bip84 maps to header 39-42; signMessageBase64 already has path.format and knows the derivation authoritatively instead of inferring it from an address string.

Suggested shape

Push the format choice down through EdgeSignMessageOptions and let the plugin do the work. This needs no core change to start — the options object is { otherParams?: JsonObject }, and this PR already passes otherParams: { publicAddress }:

wallet.signMessage(message, {
  otherParams: { publicAddress: address, signatureFormat: 'bip137' }
})

The plugin then owns the whole decision: it maps format + path.format to segwitType, and rejects Taproot/P2WSH itself, where it can actually parse the address rather than guess from a prefix. The GUI keeps just the toggle, gated on SpecialCurrencyInfo.hasSegwit (which needs completing for the missing chains). A typed signatureFormat field on EdgeSignMessageOptions would be a reasonable follow-up once the shape settles.

This also resolves #454 cleanly. Emitting BIP-137 unconditionally is a silent behavior change for existing signMessage callers (bityProvider, EdgeProviderServer); making it opt-in avoids that. Whether #454 should default to BIP-137 or standard is a compatibility call worth deciding explicitly rather than by merge order.

Smaller things

  • The menu entry is gated on a hardcoded 22-id list, though the description says "wallets whose plugin implements message signing". A capability check would match the description, and would extend to non-UTXO chains later without another list.
  • The error handling regex-matches plugin error strings (/Missing data-layer address|Could not determine address type|.../). That will rot silently the first time the plugin rewords an error.

Related

While reviewing #454 I audited the messagePrefix magic strings, since that PR makes them load-bearing for the first time — 14 of 24 were wrong (dash was the literal string 'unused', pivx had the wrong chain name, and several had a length byte that disagreed with their own string). Fixed on matthew/audit-message-prefixes with sources cited. Relevant here because the menu exposes signing for 22 UTXO chains, so those values matter for every one of them, not just BTC.

Comment thread src/components/modals/WalletListMenuModal.tsx
Comment thread src/util/bitcoinMessageSignature.ts Outdated
j0ntz added 4 commits August 3, 2026 16:50
The Sign Message scene now shows the wallet's receive address in an editable
field instead of a read-only row. Exchanges typically ask a user to prove
control of the specific address they already provided (often a previously-used
one), so the user can replace the default with that address. The wallet must
control whichever address is entered; the plugin signs with the key derived
from that address's stored derivation path and rejects any address it does not
own, surfaced as a clear error. Editing the address clears any prior signature,
and a Use default address link restores the auto-detected receive address.
Let users on SegWit chains (Bitcoin, Litecoin, DigiByte) choose between the
Standard (Electrum) and BIP-137 signature formats. BIP-137 re-encodes the
signature header byte by address script type (native SegWit 39-42, nested
SegWit 35-38) so strict external verifiers recognize the address type. The
option is hidden on non-SegWit UTXO chains, and legacy addresses are never
remapped.
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from b0e8f18 to 217c535 Compare August 3, 2026 23:50
@j0ntz

j0ntz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Adopted your suggested shape. Both PRs are updated: edge-react-gui 217c535 and edge-currency-plugins#454 4cb6e2bb.

The blocking issue

Confirmed, and it is worse than only-on-the-default-path: the scene defaults to the segwitAddress, so post-#454 there was no way to produce a standard signature at all. Fixed by pushing the choice down rather than by fixing the arithmetic.

asUtxoSignMessageOtherParams now carries signatureFormat: 'electrum' | 'bip137', defaulting to electrum via asMaybe. It threads through UtxoEngine.signMessageUtxoWalletTools.signMessageBase64keymanager.signMessageBase64, which applies segwitType only when bip137 is asked for. So #454 no longer changes behavior for bityProvider or EdgeProviderServer — they keep today's legacy headers — and merge order stops mattering.

The keymanager spec now pins both directions on bitcoin and litecoin: the default encoding produces the SAME H… header for bip44, bip49 and bip84, and only an explicit bip137 request shifts bip49 to I… and bip84 to J…. 12 cases, so a regression in either direction fails rather than silently swapping the formats.

The segwit map and header rewriting

src/util/bitcoinMessageSignature.ts is deleted, along with its unit test. Detail in the inline thread; the short version is that the prefix table, the P2WPKH_CHARS_AFTER_PREFIX length heuristic and the Taproot/P2WSH branches all went with it, since the plugin resolves the address to its derivation path and does not have to infer anything from the string. The toggle is gated on SPECIAL_CURRENCY_INFO.hasSegwit, completed for the 6 chains it was missing against coinInfo.segwit.

Your DigiByte point checks out against the source: scriptHash: [0x3f, 0x05], so both S… and 3…, and the map listed only S.

Smaller things

  • Hardcoded 22-id menu list: kept, and the doc comment corrected to match. EdgeCurrencyWallet.signMessage is non-optional in core while EdgeCurrencyEngine.signMessage is optional, so there is no capability the GUI can read without calling and catching. Follow-up in the inline thread.
  • Error regex: gone. signMessage now throws a named AddressNotOwnedError for both the not-ours and the unparseable case (the codebase already uses this pattern for InsufficientFundsError, and yaob's packError spreads own properties so name survives the bridge). The GUI branches on error.name.

Verified

Ran on the iOS sim against a build with #454 linked in via updot, signing with the wallet's own bip84 receive address bc1q7f5wkp5k0847utsec7p8v59cqj7et6wrzz7dv4, same message both times:

Format Signature Header byte
Standard (Electrum) INoC+bq5tWf/…azdA1tk= 32 (31 + recid 1)
BIP-137 KNoC+bq5tWf/…azdA1tk= 40 (39 + recid 1, native SegWit)

Identical r/s, header byte only. Both recovered and verified against that address with bitcoinMessage.verify(..., checkSegwitAlways: true). That is the case that was broken: Standard now really is 31-range post-#454.

One caveat

#6065 depends on #454's runtime behavior. Against a published plugin without it, asObject drops the unknown signatureFormat key, so BIP-137 silently degrades to a legacy header rather than erroring. They need to ship together, with the dep bump once #454 publishes.

Related

The messagePrefix audit is already absorbed — your matthew/audit-message-prefixes commit is on #454 as Audit and correct coin messagePrefix magic strings, plus a spec asserting every coin's CompactSize length byte agrees with its own string.

@j0ntz

j0ntz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after review fixes)

standard electrum signature

standard electrum signature

bip137 signature

bip137 signature

Captured by the agent's in-app test run (build-and-test).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants