UNPKG

micro-ordinals

Version:

Manage ordinals, inscriptions and runes using scure-btc-signer

354 lines (353 loc) 14.4 kB
#!/usr/bin/env node "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.select = void 0; exports.splitArgs = splitArgs; exports.input = input; const node_fs_1 = require("node:fs"); const node_path_1 = require("node:path"); const node_zlib_1 = require("node:zlib"); // @ts-ignore const input_js_1 = require("enquirer/lib/prompts/input.js"); // @ts-ignore const base_1 = require("@scure/base"); const btc_signer_1 = require("@scure/btc-signer"); // @ts-ignore const select_js_1 = require("enquirer/lib/prompts/select.js"); const index_ts_1 = require("./index.js"); /* */ const { BROTLI_MODE_GENERIC: B_GENR, BROTLI_MODE_TEXT: B_TEXT, BROTLI_MODE_FONT } = node_zlib_1.constants; // Max script limit. // Bitcoin core node won't relay transaction with bigger limit, even if they possible. // https://github.com/bitcoin/bitcoin/blob/d908877c4774c2456eed09167a5f382758e4a8a6/src/policy/policy.h#L26-L27 const MAX_STANDARD_TX_WEIGHT = 400000; // 4 * 100kvb const DUST_RELAY_TX_FEE = 3000n; // won't relay if less than this in fees? const customScripts = [index_ts_1.OutOrdinalReveal]; const ZERO_32B = '00'.repeat(32); function splitArgs(args) { const _args = []; const opts = {}; for (let i = 0; i < args.length; i++) { const cur = args[i]; if (cur.startsWith('--')) { if (i + 1 >= args.length) throw new Error(`arguments: no value for ${cur}`); const next = args[++i]; if (next.startsWith('--')) throw new Error(`arguments: no value for ${cur}, got ${next}`); opts[cur.slice(2)] = next; continue; } _args.push(cur); } return { args: _args, opts }; } const validateFloat = (s) => { const n = Number.parseFloat(s); const matches = n.toString() === s && Number.isFinite(n) && n > 0; return matches || `Number must be greater than zero`; }; const validateTxid = (s) => { try { const txid = base_1.hex.decode(s); if (txid.length !== 32) return `wrong length ${txid.length}, expected 32 bytes`; return true; } catch (e) { return `${e}`; } }; const validateIndex = (s) => { const n = Number.parseInt(s); // Index is U32LE const matches = s === `${n}` && Number.isInteger(n) && 0 <= n && n < 2 ** 32; return matches || `Number must be between 0 and ${2 ** 32}`; }; const validateAmount = (s) => { try { const n = btc_signer_1.Decimal.decode(s); if (n <= 0) return `amount should be bigger than zero`; return true; } catch (e) { return `${e}`; } }; // /Utils // UI // const underline = '\x1b[4m'; const bold = '\x1b[1m'; // const gray = '\x1b[90m'; const reset = '\x1b[0m'; const red = '\x1b[31m'; const green = '\x1b[32m'; // const yellow = '\x1b[33m'; const magenta = '\x1b[35m'; const HELP_TEXT = ` - ${bold}net:${reset} bitcoin network - ${bold}priv:${reset} taproot private key in WIF format, will be used for reveal transaction Don't use your wallet, priv should be a new one. We generate a temporary key, if none is provided - ${bold}recovery:${reset} taproot private key in WIF format, can be used to recover any bitcoins sent to inscription address by accident without paying full inscription fee. - ${bold}compress:${reset} inscriptions compressed with brotli. Compatible with explorers. default=on - ${bold}fee:${reset} bitcoin network fee in satoshis - ${bold}addr:${reset} address where inscription will be sent after reveal ${bold}Important:${reset} first sat is always inscribed. Batch inscriptions are not supported. `; const select = async (message, choices) => { try { return await new select_js_1.default({ message, choices }).run(); } catch (e) { process.exit(); // ctrl+c } }; exports.select = select; async function input(message, validate) { let opts = { message }; if (validate) opts.validate = validate; try { return await new input_js_1.default(opts).run(); } catch (e) { process.exit(); // ctrl+c } } const defaultLang = typeof navigator === 'object' ? navigator.language : undefined; const bfmt = new Intl.NumberFormat(defaultLang, { style: 'unit', unit: 'byte', }); const formatBytes = (n) => `${magenta}${bfmt.format(n)}${reset}`; const formatSatoshi = (n) => `${magenta}${n}${reset} satoshi (${magenta}${btc_signer_1.Decimal.encode(n)}${reset} BTC)`; const formatAddress = (s) => `${green}${s}${reset}`; // /UI // We support MIME types, supported by ordinals explorer. // Other MIME types can be allowed, but won't be displayed there. // Important: .txt file can actually be .jpg, etc. // prettier-ignore const contentTypeTable = [ ["application/cbor", B_GENR, [".cbor"]], ["application/json", B_TEXT, [".json"]], ["application/octet-stream", B_GENR, [".bin"]], ["application/pdf", B_GENR, [".pdf"]], ["application/pgp-signature", B_TEXT, [".asc"]], ["application/protobuf", B_GENR, [".binpb"]], ["application/yaml", B_TEXT, [".yaml", ".yml"]], ["audio/flac", B_GENR, [".flac"]], ["audio/mpeg", B_GENR, [".mp3"]], ["audio/wav", B_GENR, [".wav"]], ["font/otf", B_GENR, [".otf"]], ["font/ttf", B_GENR, [".ttf"]], ["font/woff", B_GENR, [".woff"]], ["font/woff2", BROTLI_MODE_FONT, [".woff2"]], ["image/apng", B_GENR, [".apng"]], ["image/avif", B_GENR, [".avif"]], ["image/gif", B_GENR, [".gif"]], ["image/jpeg", B_GENR, [".jpg", ".jpeg"]], ["image/png", B_GENR, [".png"]], ["image/svg+xml", B_TEXT, [".svg"]], ["image/webp", B_GENR, [".webp"]], ["model/gltf+json", B_TEXT, [".gltf"]], ["model/gltf-binary", B_GENR, [".glb"]], ["model/stl", B_GENR, [".stl"]], ["text/css", B_TEXT, [".css"]], ["text/html;charset=utf-8", B_TEXT, [".html"]], ["text/javascript", B_TEXT, [".js"]], ["text/markdown;charset=utf-8", B_TEXT, [".md"]], ["text/plain;charset=utf-8", B_TEXT, [".txt"]], ["text/x-python", B_TEXT, [".py"]], ["video/mp4", B_GENR, [".mp4"]], ["video/webm", B_GENR, [".webm"]], ]; // Some formats have multiple extensions const contentType = new Map(); for (const [type, brotliMode, exts] of contentTypeTable) { for (const ext of exts) contentType.set(ext, [type, brotliMode]); } const NETWORKS = { mainnet: { ...btc_signer_1.NETWORK, name: `${red}mainnet${reset}` }, testnet: { ...btc_signer_1.TEST_NETWORK, name: `testnet` }, }; const usage = (err) => { if (err) console.error(`${red}ERROR${reset}: ${err}`); console.log(`Usage: ${green}ord-cli${reset} [--net ${Object.keys(NETWORKS).join('|')}] [--priv key] [--recovery key] [--compress=on|off] [--fee 10.1] [--addr address] <path>`); console.log(HELP_TEXT); process.exit(); }; async function getNetwork(opts) { if (!opts.net) opts.net = await (0, exports.select)('Network', ['testnet', 'mainnet']); const NET = NETWORKS[opts.net]; if (typeof opts.net !== 'string' || !NET) return usage(`wrong network ${opts.net}. Expected: ${Object.keys(NETWORKS).join(', ')}`); console.log(`${bold}Network:${reset} ${NET.name}`); return NET; } function getKeys(net, opts) { const KEYS = { priv: 'Temporary', recovery: 'Recovery', }; const res = {}; for (const name in KEYS) { // We can probably can do taproot tweak, // but if user provided non-taproot key there would be an error? // For example user can accidentally provide key for if (opts[name]) res[name] = (0, btc_signer_1.WIF)(net).decode(opts.priv); else { res[name] = btc_signer_1.utils.randomPrivateKeyBytes(); console.log(`${KEYS[name]} private key: ${red}${(0, btc_signer_1.WIF)(net).encode(res[name])}${reset}`); } if (res[name].length !== 32) { return usage(`wrong ${KEYS[name].toLowerCase()} private key, expected 32-bytes, got ${res[name].length}`); } } console.log(`${bold}Important:${reset} if there is an issue with reveal transaction, you will need these keys to refund sent coins`); return res; } function getInscription(filePath, opts) { const stat = (0, node_fs_1.lstatSync)(filePath); if (!stat.isFile()) return usage(`path is not file "${filePath}"`); const ext = (0, node_path_1.extname)(filePath).toLowerCase(); const type = contentType.get(ext); if (!type) throw new Error(`unknown extension "${ext}"`); const [mime, brotliMode] = type; const info = []; info.push(`mime=${mime}`); let data = Uint8Array.from((0, node_fs_1.readFileSync)(filePath, null)); let inscription = { tags: { contentType: mime }, body: data }; info.push(`size=${formatBytes(data.length)}`); if (!opts.compress || opts.compress !== 'off') { const compressed = (0, node_zlib_1.brotliCompressSync)(data, { params: { [node_zlib_1.constants.BROTLI_PARAM_MODE]: brotliMode, [node_zlib_1.constants.BROTLI_PARAM_QUALITY]: node_zlib_1.constants.BROTLI_MAX_QUALITY, [node_zlib_1.constants.BROTLI_PARAM_SIZE_HINT]: data.length, }, }); // Very small files can take more space after compression if (data.length > compressed.length) { data = compressed; info.push(`compressed_size=${formatBytes(data.length)}`); inscription = { tags: { contentType: mime, contentEncoding: 'br' }, body: data, }; } } else info.push(`${red}uncompressed${reset}`); // notify user that compression disabled if (data.length > MAX_STANDARD_TX_WEIGHT) return usage(`File is too big ${data.length}. Limit ${MAX_STANDARD_TX_WEIGHT}`); console.log(`${bold}File:${reset} ${filePath} (${info.join(', ')})`); return inscription; } async function getFee(opts) { let fee = opts.fee; if (!fee) fee = await input(`Network fee (in satoshi)`, validateFloat); if (validateFloat(fee) !== true) return usage(`wrong fee=${fee}`); return parseFloat(fee); } async function getAddr(net, opts) { let address = opts.addr; const validate = (s) => { try { (0, btc_signer_1.Address)(net).decode(s); return true; } catch (e) { return `${e}`; } }; if (!address) address = await input('Change address (where inscription will be sent on reveal)', validate); if (validate(address) !== true) return usage(`wrong address=${address}`); return address; } function getPayment(privKey, recovery, inscription, net) { const pubKey = btc_signer_1.utils.pubSchnorr(privKey); const recoveryPub = btc_signer_1.utils.pubSchnorr(recovery); const rev = (0, index_ts_1.p2tr_ord_reveal)(pubKey, [inscription]); return (0, btc_signer_1.p2tr)(recoveryPub, rev, net, false, customScripts); } function getTransaction(privKey, addr, payment, net, txid, index, amount, fee) { const tx = new btc_signer_1.Transaction({ customScripts }); tx.addInput({ ...payment, txid, index, witnessUtxo: { script: payment.script, amount }, }); tx.addOutputAddress(addr, amount - fee, net); tx.sign(privKey, undefined, new Uint8Array(32)); tx.finalize(); return tx; } async function main() { try { const argv = process.argv; // @ts-ignore if (import.meta.url !== `file://${(0, node_fs_1.realpathSync)(argv[1])}`) return; // ESM is broken. if (argv.length < 3) return usage('Wrong argument count'); // node script file const { args, opts } = splitArgs(argv.slice(2)); if (args.length !== 1) return usage(`only single file supported, got ${args.length}`); const net = await getNetwork(opts); const inscription = getInscription(args[0], opts); const { priv, recovery } = getKeys(net, opts); const fee = await getFee(opts); const addr = await getAddr(net, opts); // Actual logic const payment = getPayment(priv, recovery, inscription, net); // dummy tx to estimate fees and tx size const dummyTx = getTransaction(priv, addr, payment, net, ZERO_32B, 0, DUST_RELAY_TX_FEE, 1n); if (dummyTx.weight >= MAX_STANDARD_TX_WEIGHT) { return usage(`File is too big: reveal transaction weight (${dummyTx.weight}) is higher than limit (${MAX_STANDARD_TX_WEIGHT})`); } const txFee = BigInt(Math.floor(dummyTx.vsize * fee)); console.log(`${bold}Fee:${reset} ${formatSatoshi(txFee)}`); // If output of reveal tx considered dust, it would be hard to spend later, // real limit is probably lower, but we take bigger value to be sure. // Making UTXO inscription dust can probably prevent spending as fees, // but also will prevent moving to different address. const minAmount = DUST_RELAY_TX_FEE + txFee; console.log(`Created. Please send at least ${formatSatoshi(minAmount)} to ${formatAddress(payment.address)}`); // Ask for UTXO console.log('Please enter UTXO information for transaction you sent:'); // These fields cannot be known before we send tx, // and to send tx user needs an address of inscription const txid = await input('Txid', validateTxid); const index = Number.parseInt(await input('Index', validateIndex)); const amount = btc_signer_1.Decimal.decode(await input('Amount', validateAmount)); // Real reveal transaction const tx = getTransaction(priv, addr, payment, net, txid, index, amount, txFee); console.log('Reveal transaction created.'); console.log(`${bold}Txid:${reset} ${tx.id}`); console.log(`${bold}Tx:${reset}`); console.log(base_1.hex.encode(tx.extract())); console.log(`Please broadcast this transaction to reveal inscription and transfer to your address (${formatAddress(addr)})`); console.log(`${bold}Important:${reset} please freeze this UTXO in your wallet when received to avoid sending inscription as fees for other transactions.`); } catch (e) { return usage(e); } } main(); //# sourceMappingURL=cli.js.map