UNPKG

txo_parser

Version:

Parser for TXO URI Specification (v0.2)

379 lines (322 loc) 12.7 kB
import { parseTxoUri, isValidTxoUri, formatTxoUri } from 'https://esm.sh/txo_parser' import { secp256k1, schnorr } from 'https://esm.sh/@noble/curves@1.8.1/secp256k1' // ── Base58 ────────────────────────────────────────────── const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' function b58decode(str) { const bytes = [] for (const c of str) { let carry = B58.indexOf(c) if (carry < 0) throw new Error('Invalid base58 character: ' + c) for (let j = 0; j < bytes.length; j++) { carry += bytes[j] * 58 bytes[j] = carry & 0xff carry >>= 8 } while (carry > 0) { bytes.push(carry & 0xff); carry >>= 8 } } for (const c of str) { if (c === '1') bytes.push(0); else break } return new Uint8Array(bytes.reverse()) } // ── Hash helpers ──────────────────────────────────────── export async function sha256(data) { const buf = data instanceof Uint8Array ? data : new TextEncoder().encode(data) return new Uint8Array(await crypto.subtle.digest('SHA-256', buf)) } async function doubleSha256(data) { return sha256(await sha256(data)) } // ── WIF / Key decode ──────────────────────────────────── async function wifDecode(wif) { const raw = b58decode(wif) if (raw.length < 5) throw new Error('WIF too short') const payload = raw.slice(0, -4) const checksum = raw.slice(-4) const hash = await doubleSha256(payload) for (let i = 0; i < 4; i++) { if (hash[i] !== checksum[i]) throw new Error('Invalid WIF checksum') } const version = payload[0] const isTestnet = version === 0xef const isMainnet = version === 0x80 if (!isTestnet && !isMainnet) throw new Error('Unknown WIF version: 0x' + version.toString(16)) const compressed = payload.length === 34 && payload[33] === 0x01 return { privkey: payload.slice(1, 33), compressed, testnet: isTestnet } } export function hexToBytes(hex) { if (hex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(hex)) throw new Error('Invalid 64-char hex key') const bytes = new Uint8Array(32) for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) return bytes } export function isHexKey(s) { return s.length === 64 && /^[0-9a-fA-F]{64}$/.test(s) } export async function decodeKey(input) { if (isHexKey(input)) { return { privkey: hexToBytes(input), compressed: true, testnet: true } } return wifDecode(input) } function privkeyToXOnly(privkeyBytes) { const pub = secp256k1.getPublicKey(privkeyBytes, true) return pub.slice(1) } // ── Bech32m (P2TR addresses) ──────────────────────────── const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' const BECH32M = 0x2bc830a3 function polymod(values) { const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] let chk = 1 for (const v of values) { const b = chk >> 25 chk = ((chk & 0x1ffffff) << 5) ^ v for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i] } return chk } function hrpExpand(hrp) { const r = [] for (const c of hrp) r.push(c.charCodeAt(0) >> 5) r.push(0) for (const c of hrp) r.push(c.charCodeAt(0) & 31) return r } function convertBits(data, from, to, pad) { let acc = 0, bits = 0 const ret = [], maxv = (1 << to) - 1 for (const v of data) { acc = (acc << from) | v bits += from while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv) } } if (pad && bits > 0) ret.push((acc << (to - bits)) & maxv) return ret } function bech32mEncode(hrp, version, program) { const conv = convertBits(program, 8, 5, true) const values = [version, ...conv] const enc = [...hrpExpand(hrp), ...values, 0, 0, 0, 0, 0, 0] const mod = polymod(enc) ^ BECH32M const checksum = [0,1,2,3,4,5].map(i => (mod >> (5 * (5 - i))) & 31) let result = hrp + '1' for (const v of [...values, ...checksum]) result += BECH32_CHARSET[v] return result } export function wpToP2trAddress(wpHex, testnet) { if (testnet === undefined) testnet = true const program = hexToU8(wpHex) return bech32mEncode(testnet ? 'tb' : 'bc', 1, program) } export function privkeyToAddress(privkeyBytes, testnet) { if (testnet === undefined) testnet = true const xonly = privkeyToXOnly(privkeyBytes) return bech32mEncode(testnet ? 'tb' : 'bc', 1, xonly) } // ── Byte helpers ──────────────────────────────────────── export function bytesToHex(bytes) { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } export function hexToU8(hex) { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) return bytes } function concatBytes(...arrays) { const total = arrays.reduce((s, a) => s + a.length, 0) const result = new Uint8Array(total) let off = 0 for (const a of arrays) { result.set(a, off); off += a.length } return result } // ── Tagged hash (BIP340/341) ──────────────────────────── async function taggedHash(tag, ...msgs) { const tagHash = await sha256(new TextEncoder().encode(tag)) return sha256(concatBytes(tagHash, tagHash, ...msgs)) } // ── Taproot key tweaking (BIP86) ──────────────────────── const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141') function bytesToBigInt(bytes) { let r = 0n for (const b of bytes) r = (r << 8n) | BigInt(b) return r } function bigIntToBytes(n) { const hex = n.toString(16).padStart(64, '0') return hexToU8(hex) } async function getTweakedKeys(privkeyBytes) { const xonly = privkeyToXOnly(privkeyBytes) const tweak = await taggedHash('TapTweak', xonly) const t = bytesToBigInt(tweak) let d = bytesToBigInt(privkeyBytes) const fullPub = secp256k1.getPublicKey(privkeyBytes, false) if (fullPub[64] & 1) d = SECP_N - d const tweakedD = (d + t) % SECP_N const tweakedPriv = bigIntToBytes(tweakedD) const tweakedXOnly = schnorr.getPublicKey(tweakedPriv) return { tweakedPriv, tweakedXOnly, internalXOnly: xonly } } export function p2trScript(xonlyPubkey) { return concatBytes(new Uint8Array([0x51, 0x20]), xonlyPubkey) } // ── Transaction serialization ─────────────────────────── function writeU32LE(val) { const b = new Uint8Array(4) b[0] = val & 0xff; b[1] = (val >> 8) & 0xff; b[2] = (val >> 16) & 0xff; b[3] = (val >> 24) & 0xff return b } function writeU64LE(val) { const b = new Uint8Array(8) const n = BigInt(val) for (let i = 0; i < 8; i++) b[i] = Number((n >> BigInt(i * 8)) & 0xffn) return b } function writeVarInt(val) { if (val < 0xfd) return new Uint8Array([val]) if (val <= 0xffff) return new Uint8Array([0xfd, val & 0xff, (val >> 8) & 0xff]) throw new Error('VarInt too large') } function reverseTxid(txidHex) { const bytes = hexToU8(txidHex) bytes.reverse() return bytes } export function estimateVsize(numInputs, numOutputs) { return Math.ceil((42 + 230 * numInputs + 172 * numOutputs) / 4) } export async function buildTransaction(inputs, outputs, privkeyBytes) { const internalXOnly = privkeyToXOnly(privkeyBytes) const { tweakedPriv } = await getTweakedKeys(privkeyBytes) const untweakedHex = '5120' + bytesToHex(internalXOnly) const signingKey = bytesToHex(inputs[0].scriptPubKey) === untweakedHex ? privkeyBytes : tweakedPriv const version = 2, locktime = 0, sequence = 0xfffffffd const serOutputs = outputs.map(o => concatBytes(writeU64LE(o.amount), writeVarInt(o.scriptPubKey.length), o.scriptPubKey) ) const shaPrevouts = await sha256(concatBytes(...inputs.map(i => concatBytes(reverseTxid(i.txid), writeU32LE(i.vout)) ))) const shaAmounts = await sha256(concatBytes(...inputs.map(i => writeU64LE(i.amount)))) const shaScriptPubKeys = await sha256(concatBytes(...inputs.map(i => concatBytes(writeVarInt(i.scriptPubKey.length), i.scriptPubKey) ))) const shaSequences = await sha256(concatBytes(...inputs.map(() => writeU32LE(sequence)))) const shaOutputs = await sha256(concatBytes(...serOutputs)) const sigs = [] for (let i = 0; i < inputs.length; i++) { const sigMsg = concatBytes( new Uint8Array([0x00, 0x00]), writeU32LE(version), writeU32LE(locktime), shaPrevouts, shaAmounts, shaScriptPubKeys, shaSequences, shaOutputs, new Uint8Array([0x00]), writeU32LE(i) ) const sighash = await taggedHash('TapSighash', sigMsg) sigs.push(schnorr.sign(sighash, signingKey)) } const parts = [ writeU32LE(version), new Uint8Array([0x00, 0x01]), writeVarInt(inputs.length) ] for (const inp of inputs) { parts.push(reverseTxid(inp.txid), writeU32LE(inp.vout), new Uint8Array([0x00]), writeU32LE(sequence)) } parts.push(writeVarInt(outputs.length)) for (const so of serOutputs) parts.push(so) for (const sig of sigs) { parts.push(new Uint8Array([0x01]), writeVarInt(sig.length), sig) } parts.push(writeU32LE(locktime)) return bytesToHex(concatBytes(...parts)) } // ── Mempool API ───────────────────────────────────────── const MEMPOOL = 'https://mempool.space/testnet4/api' export async function fetchUtxos(address) { const res = await fetch(MEMPOOL + '/address/' + address + '/utxo') if (!res.ok) throw new Error('Mempool API error: ' + res.status) return res.json() } export async function checkOutspend(txid, vout) { try { const res = await fetch(MEMPOOL + '/tx/' + txid + '/outspend/' + vout) if (!res.ok) return null return res.json() } catch { return null } } export async function fetchTxDetails(txid) { const res = await fetch(MEMPOOL + '/tx/' + txid) if (!res.ok) throw new Error('Failed to fetch tx: ' + res.status) return res.json() } export async function broadcastTx(rawTxHex) { const res = await fetch(MEMPOOL + '/tx', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: rawTxHex }) if (!res.ok) { const err = await res.text() throw new Error(err) } return res.text() } export async function getFeeRate() { try { const res = await fetch(MEMPOOL + '/v1/fees/recommended') if (!res.ok) return 2 const data = await res.json() return data.fastestFee || data.halfHourFee || 2 } catch { return 2 } } // ── TXO URI helpers ───────────────────────────────────── export function toSats(amount) { if (!amount) return 0 if (Number.isInteger(amount) && amount >= 1) return amount const converted = Math.round(amount * 1e8) if (converted > 2_100_000_000_000_000) return Math.round(amount) return converted } export function buildTxoUri(v) { const base = 'txo:btc:' + v.txid + ':' + v.vout const params = [] if (v.amount) params.push('amount=' + v.amount) if (v.privkey) params.push('key=' + v.privkey) return params.length ? base + '?' + params.join('&') : base } export function parseVoucherFromItem(item) { var txoUri = item['schema:identifier'] || '' var txid = '', vout = 0, amount = 0, privkey = '' if (txoUri) { try { var parsed = parseTxoUri(txoUri) txid = parsed.txid || '' vout = parsed.output || 0 amount = toSats(parsed.amount) privkey = parsed.privkey || parsed.key || '' } catch { try { var parts = txoUri.split('?') var segs = parts[0].replace(/^txo:/, '').split(':') if (segs.length >= 3) { txid = segs[1] || ''; vout = parseInt(segs[2]) || 0 } if (parts[1]) { var params = new URLSearchParams(parts[1]) if (params.has('amount')) amount = toSats(parseFloat(params.get('amount'))) if (params.has('key')) privkey = params.get('key') } } catch {} } } return { id: item['@id'] || Date.now().toString(36) + Math.random().toString(36).slice(2, 6), txid: txid, vout: vout, amount: amount, privkey: privkey, address: item['schema:address'] || '', network: 'btc', status: item['schema:status'] || 'unknown', dateAdded: item['schema:dateCreated'] || new Date().toISOString() } } export { parseTxoUri, isValidTxoUri, formatTxoUri }