import { ProofStatus, ProofTypes, SignatureProof, } from "@notabene/javascript-sdk"; import { encode as encodeLength } from "varuint-bitcoin"; import { base64, bech32, createBase58check } from "@scure/base"; import { Hash } from "ox"; import { secp256k1 } from "@noble/curves/secp256k1"; import { SignatureType } from "@noble/curves/abstract/weierstrass"; import { Verifier } from "bip322-js"; enum SEGWIT_TYPES { P2WPKH = "p2wpkh", P2SH_P2WPKH = "p2sh(p2wpkh)", } interface ChainConfig { messagePrefix: string; pubKeyHashVersion: number | Uint8Array; scriptHashVersion: number | Uint8Array; bech32Prefix?: string; isTestnet?: boolean; } const CHAIN_CONFIGS: Record = { bitcoin: { messagePrefix: "\u0018Bitcoin Signed Message:\n", pubKeyHashVersion: 0x00, // 1... scriptHashVersion: 0x05, // 3... bech32Prefix: "bc", isTestnet: false, }, bitcoincash: { messagePrefix: "\u0018Bitcoin Signed Message:\n", pubKeyHashVersion: 0x00, // 1... scriptHashVersion: 0x05, // 3... bech32Prefix: "bc", isTestnet: false, }, litecoin: { messagePrefix: "\u0019Litecoin Signed Message:\n", pubKeyHashVersion: 0x30, // L... or M... scriptHashVersion: 0x32, // 3... or M... bech32Prefix: "ltc", isTestnet: false, }, dogecoin: { messagePrefix: "\u0019Dogecoin Signed Message:\n", pubKeyHashVersion: 0x1e, // D... scriptHashVersion: 0x16, // A... isTestnet: false, }, dash: { messagePrefix: "\u0019DarkCoin Signed Message:\n", pubKeyHashVersion: 0x4c, // X... scriptHashVersion: 0x10, // 7... isTestnet: false, }, zcash: { messagePrefix: "\u0018Zcash Signed Message:\n", pubKeyHashVersion: Uint8Array.from([0x1c, 0xb8]), // <-- FIXED scriptHashVersion: Uint8Array.from([0x1c, 0xbd]), isTestnet: false, }, testnet: { messagePrefix: "\u0018Bitcoin Signed Message:\n", pubKeyHashVersion: 0x6f, // m or n scriptHashVersion: 0xc4, // 2 bech32Prefix: "tb", isTestnet: true, }, }; enum DerivationMode { LEGACY = "Legacy", NATIVE = "Native SegWit", SEGWIT = "SegWit", P2SH_SEGWIT = "p2sh", BCH = "Bitcoin Cash", ETHEREUM = "Ethereum", DOGECOIN = "Dogecoin", UNKNOWN = "Unknown", } export async function verifyBTCSignature( proof: SignatureProof ): Promise { const [ns, , address] = proof.address.split(/:/); if (ns !== "bip122") return { ...proof, status: ProofStatus.FAILED }; // Map chainId to our chain configuration const chainConfig = getChainConfig(address); if (!chainConfig) return { ...proof, status: ProofStatus.FAILED }; const isZcash = address.startsWith("t1") || address.startsWith("t3"); if (isZcash) { return verifyBIP137(address, proof, chainConfig); } // Use BIP322 for testnet addresses if (chainConfig.isTestnet) { return verifyBIP322(address, proof); } // Check if this is a Taproot address (bc1p or tb1p) const isTaproot = address.startsWith("bc1p") || address.startsWith("tb1p"); // For Taproot addresses with BIP-137 proof type, use BIP-322 verification // since BIP-137 doesn't officially support Taproot if (isTaproot && proof.type === ProofTypes.BIP137) { return verifyBIP322(address, proof); } try { switch (proof.type) { case ProofTypes.BIP137: return verifyBIP137(address, proof, chainConfig); case ProofTypes.BIP322: return verifyBIP322(address, proof); default: return { ...proof, status: ProofStatus.FAILED, }; } } catch { return { ...proof, status: ProofStatus.FAILED, }; } } function getChainConfig(address: string): ChainConfig { if ( address.startsWith("1") || address.startsWith("3") || address.startsWith("bc1") ) { return CHAIN_CONFIGS["bitcoin"]; } if (address.startsWith("t1") || address.startsWith("t3")) { return CHAIN_CONFIGS["zcash"]; } if ( address.startsWith("L") || address.startsWith("M") || address.startsWith("ltc1") ) { return CHAIN_CONFIGS["litecoin"]; } if (address.startsWith("D") || address.startsWith("A")) { return CHAIN_CONFIGS["dogecoin"]; } if (address.startsWith("X") || address.startsWith("7")) { return CHAIN_CONFIGS["dash"]; } if (address.startsWith("q")) { return CHAIN_CONFIGS["bitcoincash"]; } if (address.startsWith("tb1")) { return CHAIN_CONFIGS["testnet"]; } return CHAIN_CONFIGS["bitcoin"]; } function verifyBIP322(address: string, proof: SignatureProof) { const { attestation, proof: signatureProof } = proof; const verified = Verifier.verifySignature( address, attestation, signatureProof ); return { ...proof, status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED, }; } function verifyBIP137( address: string, proof: SignatureProof, chainConfig: ChainConfig ) { const derivationMode = getDerivationMode(address); // For legacy addresses (starting with "1"), never use SegWit encoding // For P2SH addresses (starting with "3"), use SegWit encoding if they have bech32 support // For native SegWit addresses (bc1, tb1, ltc1), always use SegWit encoding const useSegwitEncoding = Boolean( chainConfig.bech32Prefix && (derivationMode === DerivationMode.NATIVE || (derivationMode === DerivationMode.SEGWIT && !address.startsWith("1"))) ); const verified = verify( proof.attestation, address, proof.proof, useSegwitEncoding, chainConfig ); return { ...proof, status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED, }; } function getDerivationMode(address: string) { if (address.match("^(bc1|tb1|ltc1).*")) { return DerivationMode.NATIVE; } else if (address.match("^[32M].*")) { return DerivationMode.SEGWIT; } else if (address.match("^[1nmL].*")) { return DerivationMode.LEGACY; } else if (address.match("^(D).*")) { return DerivationMode.DOGECOIN; } else if (address.match("^(q).*")) { return DerivationMode.BCH; } else if (address.match("^(t1|t3).*")) { return DerivationMode.LEGACY; // Zcash addresses } else if (address.match("^[X7].*")) { return DerivationMode.LEGACY; // Dash addresses } else { throw new Error( "INVALID ADDRESS: " .concat(address) .concat(" is not a valid or a supported address") ); } } type DecodedSignature = { compressed: boolean; segwitType?: SEGWIT_TYPES; signature: SignatureType; }; function decodeSignature(proof: string): DecodedSignature { const sigbytes = base64.decode(proof); if (sigbytes.length !== 65) throw new Error("Invalid signature length"); const flagByte = sigbytes[0] - 27; if (flagByte > 15 || flagByte < 0) { throw new Error("Invalid signature parameter"); } const compressed = !!(flagByte & 12); // Are there cases that aren't compressed? const recovery = flagByte & 3; const signature = secp256k1.Signature.fromCompact(sigbytes.slice(1)); return { compressed, segwitType: !(flagByte & 8) ? undefined : !(flagByte & 4) ? SEGWIT_TYPES.P2SH_P2WPKH : SEGWIT_TYPES.P2WPKH, signature: signature.addRecoveryBit(recovery), }; } function verify( attestation: string, address: string, proof: string, checkSegwitAlways: boolean, chainConfig: ChainConfig ) { const { compressed, segwitType, signature } = decodeSignature(proof); if (checkSegwitAlways && !compressed) { throw new Error( "checkSegwitAlways can only be used with a compressed pubkey signature flagbyte" ); } const hash = magicHash(attestation, chainConfig.messagePrefix); const publicKey = signature.recoverPublicKey(hash); const publicKeyBytes = publicKey.toRawBytes(compressed); const publicKeyHash = hash160(publicKeyBytes); let actual: string = ""; // Special handling for Bitcoin Cash addresses if (address.startsWith("q")) { // For BCH, we'll compare the public key hash directly since we're getting a CashAddr // Convert the CashAddr to legacy format for comparison actual = encodeBase58AddressFormat( chainConfig.pubKeyHashVersion, publicKeyHash ); // Legacy P2PKH addresses in BCH start with '1' just like BTC // Source: https://reference.cash/protocol/blockchain/encoding/cashaddr#legacy-address-format return actual.startsWith("1"); } if (segwitType) { if (segwitType === SEGWIT_TYPES.P2SH_P2WPKH) { actual = encodeBase58AddressFormat( chainConfig.scriptHashVersion, publicKeyHash ); } else { // parsed.segwitType === SEGWIT_TYPES.P2WPKH if (chainConfig.bech32Prefix) { actual = encodeBech32Address(publicKeyHash, chainConfig.bech32Prefix); } else { // Fallback to legacy if bech32 not supported actual = encodeBase58AddressFormat( chainConfig.scriptHashVersion, publicKeyHash ); // base58 can be p2pkh or p2sh-p2wpkh } } } else { // For addresses starting with "3" (P2SH), try both P2SH-P2WPKH and legacy P2SH encodings if segwitType is undefined if (address.startsWith("3") && !segwitType) { // P2SH-P2WPKH: script hash of the redeem script (OP_0 ) const redeemScript = new Uint8Array(22); redeemScript[0] = 0x00; // OP_0 redeemScript[1] = 0x14; // push 20 bytes redeemScript.set(publicKeyHash, 2); const redeemScriptHash = hash160(redeemScript); const p2shP2wpkh = encodeBase58AddressFormat( chainConfig.scriptHashVersion, redeemScriptHash ); // Legacy P2SH: script hash of the public key const legacyP2sh = encodeBase58AddressFormat( chainConfig.scriptHashVersion, publicKeyHash ); if (address === p2shP2wpkh || address === legacyP2sh) { return true; } actual = legacyP2sh; // fallback for error reporting } else if (address.startsWith("bc1q") || address.startsWith("tb1q") || address.startsWith("ltc1q")) { // For native SegWit P2WPKH addresses (bc1q/tb1q/ltc1q), always encode as bech32 // This handles Ledger wallets that sign without segwit flags if (chainConfig.bech32Prefix) { actual = encodeBech32Address(publicKeyHash, chainConfig.bech32Prefix); } else { actual = encodeBase58AddressFormat( chainConfig.pubKeyHashVersion, publicKeyHash ); } } else if (checkSegwitAlways && chainConfig.bech32Prefix) { try { actual = encodeBech32Address(publicKeyHash, chainConfig.bech32Prefix); // if address is bech32 it is not p2sh // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { actual = encodeBase58AddressFormat( chainConfig.scriptHashVersion, publicKeyHash ); // base58 can be p2pkh or p2sh-p2wpkh } } else { actual = encodeBase58AddressFormat( chainConfig.pubKeyHashVersion, publicKeyHash ); } } return actual === address; } const base58check = createBase58check(Hash.sha256); function encodeBase58AddressFormat( version: number | Uint8Array, publicKeyHash: Uint8Array ) { const prefixBytes = typeof version === "number" ? Uint8Array.of(version) : version; // Accept raw Uint8Array for Zcash const payload = new Uint8Array(prefixBytes.length + publicKeyHash.length); payload.set(prefixBytes); payload.set(publicKeyHash, prefixBytes.length); return base58check.encode(payload); } function magicHash(attestation: string, messagePrefix: string) { const prefix = new TextEncoder().encode(messagePrefix); const message = new TextEncoder().encode(attestation); const length = encodeLength(message.length).buffer; const buffer = new Uint8Array( prefix.length + length.byteLength + message.length ); buffer.set(prefix); buffer.set(new Uint8Array(length), prefix.length); buffer.set(message, prefix.length + length.byteLength); return hash256(buffer); } function encodeBech32Address( publicKeyHash: Uint8Array, prefix: string = "bc" ): string { const bwords = bech32.toWords(publicKeyHash); bwords.unshift(0); return bech32.encode(prefix, bwords); } function hash256(buffer: Uint8Array): Uint8Array { return Hash.sha256(Hash.sha256(buffer)); } function hash160(buffer: Uint8Array): Uint8Array { return Hash.ripemd160(Hash.sha256(buffer)); }