chaingate
Version:
Multi-chain cryptocurrency SDK for TypeScript — unified API for Bitcoin, Ethereum, Litecoin, Dogecoin, Bitcoin Cash, Polygon, Arbitrum, and any EVM-compatible chain. Create wallets, query balances, send transactions, and manage tokens and NFTs across UTXO
203 lines (202 loc) • 8.68 kB
JavaScript
"use strict";
/**
* Message signing and verification for EVM (EIP-191) and UTXO (Bitcoin-style)
* networks.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.signEvmMessage = signEvmMessage;
exports.verifyEvmMessage = verifyEvmMessage;
exports.signUtxoMessage = signUtxoMessage;
exports.recoverUtxoPublicKey = recoverUtxoPublicKey;
const secp256k1_js_1 = require("@noble/curves/secp256k1.js");
const sha2_js_1 = require("@noble/hashes/sha2.js");
const sha3_js_1 = require("@noble/hashes/sha3.js");
const encoding_1 = require("./encoding");
const crypto_1 = require("./crypto");
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function encodeVarInt(n) {
if (n < 0xfd) {
return Uint8Array.of(n);
}
else if (n <= 0xffff) {
return Uint8Array.of(0xfd, n & 0xff, (n >> 8) & 0xff);
}
else if (n <= 0xffffffff) {
return Uint8Array.of(0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff);
}
else {
throw new RangeError('Message too long');
}
}
// ---------------------------------------------------------------------------
// EVM (EIP-191) message signing
// ---------------------------------------------------------------------------
/**
* Hashes a message using the EIP-191 "personal sign" standard.
*
* Format: `keccak256("\x19Ethereum Signed Message:\n" + len + message)`
*/
function eip191Hash(message) {
const messageBytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${messageBytes.length}`);
const payload = new Uint8Array(prefix.length + messageBytes.length);
payload.set(prefix);
payload.set(messageBytes, prefix.length);
return (0, sha3_js_1.keccak_256)(payload);
}
/**
* Signs a message using EIP-191 personal sign (the same scheme as MetaMask's
* `personal_sign`).
*
* @param message - The message to sign (string or raw bytes).
* @param privateKey - The 32-byte private key.
* @returns The 65-byte signature as a hex string with `0x` prefix.
*/
function signEvmMessage(message, privateKey) {
const hash = eip191Hash(message);
// 'recovered' format: Uint8Array of 65 bytes — [recovery(1), r(32), s(32)]
const sigBytes = secp256k1_js_1.secp256k1.sign(hash, privateKey, {
prehash: false,
lowS: true,
format: 'recovered',
});
const recovery = sigBytes[0]; // 0 or 1
const r = sigBytes.subarray(1, 33);
const s = sigBytes.subarray(33, 65);
const v = (recovery + 27).toString(16).padStart(2, '0');
return '0x' + (0, encoding_1.bytesToHex)(r) + (0, encoding_1.bytesToHex)(s) + v;
}
/**
* Verifies an EIP-191 personal sign signature.
*
* @param message - The original message (string or raw bytes).
* @param signature - The signature (hex string with `0x` prefix).
* @param address - The expected signer address (EIP-55 checksummed or lowercase).
* @returns `true` if the signature is valid and was produced by the given address.
*/
function verifyEvmMessage(message, signature, address) {
try {
const hash = eip191Hash(message);
const sigHexBytes = (0, encoding_1.hexToBytes)(signature);
if (sigHexBytes.length !== 65)
return false;
const r = sigHexBytes.slice(0, 32);
const s = sigHexBytes.slice(32, 64);
const v = sigHexBytes[64];
const recovery = v >= 27 ? v - 27 : v;
if (recovery !== 0 && recovery !== 1)
return false;
// Reconstruct 'recovered' format: [recovery, r, s]
const recovered = new Uint8Array(65);
recovered[0] = recovery;
recovered.set(r, 1);
recovered.set(s, 33);
const recoveredPubKeyBytes = secp256k1_js_1.secp256k1.recoverPublicKey(recovered, hash, { prehash: false });
const recoveredAddress = (0, crypto_1.publicKeyToEthAddress)(recoveredPubKeyBytes);
return recoveredAddress.toLowerCase() === address.toLowerCase();
}
catch {
return false;
}
}
// ---------------------------------------------------------------------------
// UTXO (Bitcoin-style) message signing
// ---------------------------------------------------------------------------
/**
* Computes the Bitcoin "magic hash" for message signing.
*
* Format: `SHA256(SHA256(prefix_len + prefix + message_len + message))`
*
* @param message - The message to hash.
* @param prefix - The chain-specific sign header (e.g. `"\x18Bitcoin Signed Message:\n"`).
*/
function magicHash(message, prefix = '\x18Bitcoin Signed Message:\n') {
const prefixBytes = new TextEncoder().encode(prefix);
const prefixLen = encodeVarInt(prefixBytes.length);
const messageBytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
const msgLen = encodeVarInt(messageBytes.length);
const payload = new Uint8Array(prefixLen.length + prefixBytes.length + msgLen.length + messageBytes.length);
let offset = 0;
payload.set(prefixLen, offset);
offset += prefixLen.length;
payload.set(prefixBytes, offset);
offset += prefixBytes.length;
payload.set(msgLen, offset);
offset += msgLen.length;
payload.set(messageBytes, offset);
return (0, sha2_js_1.sha256)((0, sha2_js_1.sha256)(payload));
}
/**
* Signs a message using the Bitcoin message signing standard.
*
* @param message - The message to sign (string or raw bytes).
* @param privateKey - The 32-byte private key.
* @param prefix - The chain-specific sign header. Defaults to Bitcoin's prefix.
* @returns The signature as a base64 string (65 bytes).
*/
function signUtxoMessage(message, privateKey, prefix = '\x18Bitcoin Signed Message:\n') {
const hash = magicHash(message, prefix);
// 'recovered' format: [recovery(1), r(32), s(32)]
const sigBytes = secp256k1_js_1.secp256k1.sign(hash, privateKey, {
prehash: false,
lowS: true,
format: 'recovered',
});
const recovery = sigBytes[0]; // 0 or 1
// Get expected public key (compressed)
const expectedPubKey = secp256k1_js_1.secp256k1.getPublicKey(privateKey, true);
// Try the actual recovery first, then fallback
for (const recoveryId of [recovery, ...[0, 1, 2, 3].filter((r) => r !== recovery)]) {
const candidate = new Uint8Array(65);
candidate[0] = recoveryId;
candidate.set(sigBytes.subarray(1, 65), 1);
let recoveredPubKey;
try {
recoveredPubKey = (0, crypto_1.compressPublicKey)(secp256k1_js_1.secp256k1.recoverPublicKey(candidate, hash, { prehash: false }));
}
catch {
continue;
}
// Compare the recovered public key with expected
if (recoveredPubKey.length === expectedPubKey.length &&
recoveredPubKey.every((byte, i) => byte === expectedPubKey[i])) {
// Use 31 for compressed keys (Bitcoin standard)
const recoveryFlag = 31 + recoveryId;
const fullSig = new Uint8Array(65);
fullSig[0] = recoveryFlag;
fullSig.set(sigBytes.subarray(1, 65), 1);
return btoa(String.fromCharCode(...fullSig));
}
}
throw new RangeError('Could not create recoverable signature');
}
/**
* Recovers the compressed public key from a Bitcoin-style signed message.
*
* @param message - The original message.
* @param signature - The base64-encoded signature (65 bytes).
* @param prefix - The chain-specific sign header.
* @returns The recovered compressed public key (33 bytes).
*/
function recoverUtxoPublicKey(message, signature, prefix = '\x18Bitcoin Signed Message:\n') {
const hash = magicHash(message, prefix);
const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
if (sigBytes.length !== 65) {
throw new RangeError('Invalid signature length');
}
const recoveryFlag = sigBytes[0];
const signatureData = sigBytes.slice(1);
const isCompressed = recoveryFlag >= 31;
const recoveryId = isCompressed ? recoveryFlag - 31 : recoveryFlag - 27;
if (recoveryId < 0 || recoveryId > 3) {
throw new RangeError('Invalid recovery ID');
}
// Build 'recovered' format: [recovery, r, s]
const recovered = new Uint8Array(65);
recovered[0] = recoveryId;
recovered.set(signatureData, 1);
const uncompressed = secp256k1_js_1.secp256k1.recoverPublicKey(recovered, hash, { prehash: false });
return isCompressed ? (0, crypto_1.compressPublicKey)(uncompressed) : uncompressed;
}