micro-ordinals
Version:
Manage ordinals, inscriptions and runes using scure-btc-signer
395 lines (365 loc) • 14.5 kB
text/typescript
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { extname } from 'node:path';
import { brotliCompressSync, constants as zlibc } from 'node:zlib';
// @ts-ignore
import Input from 'enquirer/lib/prompts/input.js';
// @ts-ignore
import { hex } from '@scure/base';
import {
Address,
Decimal,
NETWORK,
TEST_NETWORK,
Transaction,
WIF,
p2tr,
utils,
} from '@scure/btc-signer';
// @ts-ignore
import Select from 'enquirer/lib/prompts/select.js';
import { type Inscription, OutOrdinalReveal, p2tr_ord_reveal } from './index.ts';
/*
*/
const { BROTLI_MODE_GENERIC: B_GENR, BROTLI_MODE_TEXT: B_TEXT, BROTLI_MODE_FONT } = zlibc;
// 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 = [OutOrdinalReveal];
const ZERO_32B = '00'.repeat(32);
// Utils
type Opts = Record<string, string>;
export function splitArgs(args: string[]): { args: string[]; opts: Opts } {
const _args: string[] = [];
const opts: 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: string) => {
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: string) => {
try {
const txid = hex.decode(s);
if (txid.length !== 32) return `wrong length ${txid.length}, expected 32 bytes`;
return true;
} catch (e) {
return `${e}`;
}
};
const validateIndex = (s: string) => {
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: string) => {
try {
const n = 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.
`;
type InputValidate = (input: string) => boolean | string | Promise<boolean | string>;
export const select = async (message: string, choices: string[]): Promise<any> => {
try {
return await new Select({ message, choices }).run();
} catch (e) {
process.exit(); // ctrl+c
}
};
export async function input(message: string, validate?: InputValidate): Promise<any> {
let opts: { message: string; validate?: InputValidate } = { message };
if (validate) opts.validate = validate;
try {
return await new Input(opts).run();
} catch (e) {
process.exit(); // ctrl+c
}
}
declare const navigator: any;
const defaultLang = typeof navigator === 'object' ? navigator.language : undefined;
const bfmt = new Intl.NumberFormat(defaultLang, {
style: 'unit',
unit: 'byte',
});
const formatBytes = (n: number) => `${magenta}${bfmt.format(n)}${reset}`;
const formatSatoshi = (n: bigint) =>
`${magenta}${n}${reset} satoshi (${magenta}${Decimal.encode(n)}${reset} BTC)`;
const formatAddress = (s: string) => `${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: [string, number, string[]][] = [
["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: Map<string, [string, number]> = new Map();
for (const [type, brotliMode, exts] of contentTypeTable) {
for (const ext of exts) contentType.set(ext, [type, brotliMode]);
}
const NETWORKS: Record<string, typeof NETWORK & { name: string }> = {
mainnet: { ...NETWORK, name: `${red}mainnet${reset}` },
testnet: { ...TEST_NETWORK, name: `testnet` },
};
type NET = (typeof NETWORKS)[keyof typeof NETWORKS];
const usage = (err?: Error | string) => {
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: Opts) {
if (!opts.net) opts.net = await 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: NET, opts: Opts) {
const KEYS: Record<string, string> = {
priv: 'Temporary',
recovery: 'Recovery',
};
const res: Record<string, Uint8Array> = {};
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] = WIF(net).decode(opts.priv);
else {
res[name] = utils.randomPrivateKeyBytes();
console.log(`${KEYS[name]} private key: ${red}${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 as { priv: Uint8Array; recovery: Uint8Array };
}
function getInscription(filePath: string, opts: Opts) {
const stat = lstatSync(filePath);
if (!stat.isFile()) return usage(`path is not file "${filePath}"`);
const ext = extname(filePath).toLowerCase();
const type = contentType.get(ext);
if (!type) throw new Error(`unknown extension "${ext}"`);
const [mime, brotliMode] = type;
const info: string[] = [];
info.push(`mime=${mime}`);
let data = Uint8Array.from(readFileSync(filePath, null));
let inscription: Inscription = { tags: { contentType: mime }, body: data };
info.push(`size=${formatBytes(data.length)}`);
if (!opts.compress || opts.compress !== 'off') {
const compressed = brotliCompressSync(data, {
params: {
[zlibc.BROTLI_PARAM_MODE]: brotliMode,
[zlibc.BROTLI_PARAM_QUALITY]: zlibc.BROTLI_MAX_QUALITY,
[zlibc.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: 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: NET, opts: Opts) {
let address = opts.addr;
const validate = (s: string) => {
try {
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: Uint8Array, recovery: Uint8Array, inscription: Inscription, net: NET) {
const pubKey = utils.pubSchnorr(privKey);
const recoveryPub = utils.pubSchnorr(recovery);
const rev = p2tr_ord_reveal(pubKey, [inscription]);
return p2tr(recoveryPub, rev, net, false, customScripts);
}
function getTransaction(
privKey: Uint8Array,
addr: string,
payment: ReturnType<typeof getPayment>,
net: NET,
txid: string,
index: number,
amount: bigint,
fee: bigint
) {
const tx = new 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://${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 = 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(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 as Error);
}
}
main();