Verifying wallet signatures across EVM, Solana, and Tron
Three signature schemes, three wire formats, one product surface. A walkthrough of how we cryptographically verify a merchant owns the wallet they say they own.
When a merchant adds a settlement wallet, we don't take their word for it. They sign a challenge string with the wallet's private key and we cryptographically verify the signature server-side. If we got this wrong, an attacker could redirect somebody else's settlement funds to a wallet they control. So getting it right across three different signature schemes is non-negotiable.
The three schemes
- EVM (Ethereum, Base, Polygon, Arbitrum) — secp256k1 over a keccak256 hash, with the ERC-191 / EIP-191 prefix. We verify with viem's recoverMessageAddress.
- Solana — Ed25519 over the raw challenge bytes. We verify with Node's built-in crypto.verify.
- Tron — secp256k1 over a keccak256 hash with the TIP-191 prefix ("\x19TRON Signed Message:\n32"). The signature recovery is identical to ERC-191 once you swap the prefix.
Why we own the verifier
It's tempting to outsource this to a wallet-connect SDK — let the wallet tell you whether the signature is valid. We don't. Signature verification is the kind of code where a silent dependency upgrade can produce signatures that prod-incompatible. We pin it in tests:
// Pin the wire-format. A viem upgrade silently changing ERC-191 hashing
// trips this test instead of producing prod-incompatible signatures.
test("ERC-191 v=27 over fixed challenge", () => {
const expected = "0x7e1a..." // generated once with a known private key
const sig = signERC191(KNOWN_PRIVKEY, CHALLENGE);
expect(sig).toBe(expected);
});Same approach for the Tron TIP-191 byte sequence and the Solana Ed25519 wire encoding. If the test trips, somebody upgraded a dep and we want to know before that change ships.
The address-shape gotcha
The other half of the problem is making sure the address you're verifying matches the address you're storing. A common bug: you accept a checksummed EVM address from the user, normalise it to lowercase for storage, and then a chain-reader query (which expects checksummed) returns no results. We funnel every address through a single normaliser that knows the per-chain shape:
export function normalizeAddressForChain(chain: ChainId, raw: string) {
if (isEvmChain(chain)) return getAddress(raw); // checksummed EIP-55
if (chain === "solana") return raw; // base58 — case-sensitive, no normalization
if (chain === "tron") return tronToHex(raw); // T-prefix base58check → 0x… hex
assertNever(chain);
}Wallet inserts and chain-ingest lookups both go through this — never hand-roll a getAddress() call elsewhere or you'll desync. This is one of the gotchas it's worth being deliberately religious about.
Refund recipients get the same treatment
When a merchant initiates a refund, we re-validate the recipient address against the chain's expected shape before signing the refund tx. The recipient comes from payment metadata that was set at confirm time — JSONB metadata is not trusted input, even when it was set by us, even when the row was last touched by the chain reader. If the address doesn't match the chain's shape, the refund refuses to broadcast.
If you're building a non-custodial platform that touches multiple chains, the order of operations is: pin the wire format in tests, normalise addresses in one place, never trust JSONB. The crypto is the easy part.