Skip to content

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

Merged
j0ntz merged 4 commits into
developfrom
jon/sign-message-cex
Aug 5, 2026
Merged

Add wallet Sign Message flow for CEX ownership verification#6065
j0ntz merged 4 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.

Also driven on a non-Bitcoin, non-SegWit chain after pivx was added to the menu list: signing from My PIVX 2 (DQ5rwtRirM79cuuuhYmvR4Lm3fDuFyyn9y) produced header byte 31 and verifies under PIVX's own \x18DarkNet Signed Message:\n prefix, while failing under Bitcoin's. That exercises #454's per-coin messagePrefix correction on a coin it fixed, and the Signature Format section correctly does not render for a chain without SegWit.

Re-selecting the signature format already in effect leaves an existing signature intact (verified on the sim after the fix).

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
Signing uses local keys and depends on plugin behavior for format encoding; newly set hasSegwit flags also affect PaymentProto fee buffering on those chains.

Overview
Adds a Sign Message path from the wallet list menu (scoped to listed UTXO/Bitcoin-family plugin IDs) so users can satisfy exchange ownership checks by signing an exchange-provided message with an address this wallet controls.

New SignMessageScene lets users edit the signing address (defaulting to the wallet’s SegWit or public receive address), paste the message, sign via wallet.signMessage with publicAddress and optional signatureFormat (electrum vs bip137), and copy the result. SegWit-capable chains show a format picker driven by hasSegwit on SPECIAL_CURRENCY_INFO, which is documented and set for several chains that were missing it. Stale signatures are cleared when address, message, or format changes; AddressNotOwnedError maps to user-facing copy.

Navigation and types register the signMessage route; strings and changelog document the feature. Intended to pair with edge-currency-plugins support for signatureFormat header-byte encoding.

Reviewed by Cursor Bugbot for commit be9e9e8. 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
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 3 times, most recently 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
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).

@peachbits peachbits 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.

Both of my earlier requests check out. SEGWIT_SIGN_CHAINS and src/util/bitcoinMessageSignature.ts are gone, and I verified the hasSegwit list against coinInfo.segwit in edge-currency-plugins — 10 of 11, with bitcoingoldtestnet correctly absent since it has no SPECIAL_CURRENCY_INFO entry. Leaving EVMs as a follow-up is fine.

Three new items below, all independent of each other.

Comment thread src/constants/WalletAndCurrencyConstants.ts
Comment thread src/components/modals/WalletListMenuModal.tsx
Comment thread src/locales/en_US.ts Outdated
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 217c535 to 0017808 Compare August 5, 2026 21:06
Comment thread src/components/modals/WalletListMenuModal.tsx
Comment thread src/locales/en_US.ts Outdated
@j0ntz

j0ntz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed; details in the inline threads.

  • Menu list: ecash and pivx added. Diffed against every pluginId under src/common/utxobased/info/ in edge-currency-plugins, so it is now the full 24.
  • Unused strings: sign_message_address_label and sign_message_input_label dropped from en_US.ts and enUS.json.
  • hasSegwit covering two behaviors: kept as a follow-up, per your suggestion. I built the paymentProtoFeeMultiplier split and backed it out; the reason is in that thread (touching PaymentProtoActions.tsx graduates eleven pre-existing warnings to errors, several of them strict-boolean-expressions on any/nullable values in the invoice path, which I cannot retest against a live invoice from here).

Separately on EdgeApp/edge-currency-plugins#454: your purposeType point is fixed there. The lookup now sits behind a getBip137SegwitType helper that returns before touching bip43PurposeNumberToTypeEnum on any non-BIP137 format, so a future bip86 could only break BIP137 signing, not the default path. Bugbot independently flagged the same thing on that HEAD.

@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 0017808 to 10dd209 Compare August 5, 2026 21:32
@j0ntz

j0ntz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after review round 2)

pivx sign message entry

pivx sign message entry

pivx signature

pivx signature

btc bip137 regression

btc bip137 regression

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

@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 high 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 10dd209. Configure here.

Comment thread src/components/scenes/SignMessageScene.tsx
@j0ntz
j0ntz force-pushed the jon/sign-message-cex branch from 10dd209 to 8663a69 Compare August 5, 2026 21:56
@j0ntz

j0ntz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: format re-select keeps the signature

format reselect keeps signature

format reselect keeps signature

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

j0ntz added 4 commits August 5, 2026 16:31
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.
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 8663a69 to be9e9e8 Compare August 5, 2026 23:33
@j0ntz
j0ntz enabled auto-merge August 5, 2026 23:33
@j0ntz
j0ntz merged commit b88c884 into develop Aug 5, 2026
8 checks passed
@j0ntz
j0ntz deleted the jon/sign-message-cex branch August 5, 2026 23:55
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