diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 8d47a26..c318c31 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -9,6 +9,7 @@ void wasm; export * as address from "./address.js"; export * as ast from "./ast/index.js"; export * as bip322 from "./bip322/index.js"; +export * as inscriptions from "./inscriptions.js"; export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; export * as bip32 from "./bip32.js"; @@ -22,6 +23,7 @@ export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; export type { CoinName } from "./coinName.js"; export type { Triple } from "./triple.js"; export type { AddressFormat } from "./address.js"; +export type { TapLeafScript, PreparedInscriptionRevealData } from "./inscriptions.js"; // TODO: the exports below should be namespaced under `descriptor` in the future diff --git a/packages/wasm-utxo/js/inscriptions.ts b/packages/wasm-utxo/js/inscriptions.ts new file mode 100644 index 0000000..0f23c1f --- /dev/null +++ b/packages/wasm-utxo/js/inscriptions.ts @@ -0,0 +1,130 @@ +/** + * Inscription support for Bitcoin Ordinals + * + * This module provides functionality for creating and signing inscription + * reveal transactions following the Ordinals protocol. + * + * @see https://docs.ordinals.com/inscriptions.html + */ + +import { InscriptionsNamespace } from "./wasm/wasm_utxo.js"; +import { Transaction } from "./transaction.js"; +import { type ECPairArg, ECPair } from "./ecpair.js"; + +/** + * Taproot leaf script data needed for spending + */ +export type TapLeafScript = { + /** Leaf version (typically 0xc0 for TapScript) */ + leafVersion: number; + /** The compiled script bytes */ + script: Uint8Array; + /** Control block for script path spending */ + controlBlock: Uint8Array; +}; + +/** + * Prepared data for an inscription reveal transaction + */ +export type PreparedInscriptionRevealData = { + /** The commit output script (P2TR, network-agnostic) */ + outputScript: Uint8Array; + /** Estimated virtual size of the reveal transaction */ + revealTransactionVSize: number; + /** Tap leaf script for spending the commit output */ + tapLeafScript: TapLeafScript; +}; + +/** + * Create inscription reveal data including the commit output script and tap leaf script + * + * This function creates all the data needed to perform an inscription: + * 1. A P2TR output script for the commit transaction (network-agnostic) + * 2. The tap leaf script needed to spend from that output + * 3. An estimate of the reveal transaction's virtual size for fee calculation + * + * @param key - The key pair (ECPairArg: Uint8Array, ECPair, or WasmECPair). The x-only public key will be extracted. + * @param contentType - MIME type of the inscription (e.g., "text/plain", "image/png") + * @param inscriptionData - The inscription data bytes + * @returns PreparedInscriptionRevealData containing output script, vsize estimate, and tap leaf script + * + * @example + * ```typescript + * const revealData = createInscriptionRevealData( + * ecpair, + * "text/plain", + * new TextEncoder().encode("Hello, Ordinals!"), + * ); + * // Use address.fromOutputScriptWithCoin() to get address for a specific network + * console.log(`Estimated reveal vsize: ${revealData.revealTransactionVSize}`); + * ``` + */ +export function createInscriptionRevealData( + key: ECPairArg, + contentType: string, + inscriptionData: Uint8Array, +): PreparedInscriptionRevealData { + // Convert to ECPair and extract x-only public key (strip parity byte from compressed pubkey) + const ecpair = ECPair.from(key); + const compressedPubkey = ecpair.publicKey; + const xOnlyPubkey = compressedPubkey.slice(1); // Remove first byte (parity) + + // Call snake_case WASM method (traits output camelCase) + return InscriptionsNamespace.create_inscription_reveal_data( + xOnlyPubkey, + contentType, + inscriptionData, + ) as PreparedInscriptionRevealData; +} + +/** + * Sign a reveal transaction + * + * Creates and signs the reveal transaction that spends from the commit output + * and sends the inscription to the recipient. + * + * @param key - The private key (ECPairArg: Uint8Array, ECPair, or WasmECPair) + * @param tapLeafScript - The tap leaf script from createInscriptionRevealData + * @param commitTx - The commit transaction + * @param commitOutputScript - The commit output script (P2TR) + * @param recipientOutputScript - Where to send the inscription (output script) + * @param outputValueSats - Value in satoshis for the inscription output + * @returns The signed PSBT as bytes + * + * @example + * ```typescript + * const psbtBytes = signRevealTransaction( + * privateKey, + * revealData.tapLeafScript, + * commitTx, + * revealData.outputScript, + * recipientOutputScript, + * 10000n, // 10,000 sats + * ); + * ``` + */ +export function signRevealTransaction( + key: ECPairArg, + tapLeafScript: TapLeafScript, + commitTx: Transaction, + commitOutputScript: Uint8Array, + recipientOutputScript: Uint8Array, + outputValueSats: bigint, +): Uint8Array { + // Convert to ECPair to get private key bytes + const ecpair = ECPair.from(key); + const privateKey = ecpair.privateKey; + if (!privateKey) { + throw new Error("ECPair must have a private key for signing"); + } + + // Call snake_case WASM method + return InscriptionsNamespace.sign_reveal_transaction( + privateKey, + tapLeafScript, + commitTx.wasm, + commitOutputScript, + recipientOutputScript, + outputValueSats, + ); +} diff --git a/packages/wasm-utxo/src/inscriptions/envelope.rs b/packages/wasm-utxo/src/inscriptions/envelope.rs new file mode 100644 index 0000000..c2bf3db --- /dev/null +++ b/packages/wasm-utxo/src/inscriptions/envelope.rs @@ -0,0 +1,129 @@ +//! Inscription envelope script builder +//! +//! Creates the taproot script containing the inscription data following +//! the Ordinals protocol format. + +use miniscript::bitcoin::opcodes::all::{ + OP_CHECKSIG, OP_ENDIF, OP_IF, OP_PUSHBYTES_0, OP_PUSHNUM_1, +}; +use miniscript::bitcoin::opcodes::OP_FALSE; +use miniscript::bitcoin::script::{Builder, PushBytesBuf}; +use miniscript::bitcoin::secp256k1::XOnlyPublicKey; +use miniscript::bitcoin::ScriptBuf; + +/// Maximum size of a single data push in tapscript (520 bytes) +const MAX_PUSH_SIZE: usize = 520; + +/// Split data into chunks of at most MAX_PUSH_SIZE bytes +fn split_into_chunks(data: &[u8]) -> Vec<&[u8]> { + data.chunks(MAX_PUSH_SIZE).collect() +} + +/// Build an inscription envelope script +/// +/// The script follows the Ordinals protocol format: +/// ```text +/// OP_CHECKSIG OP_FALSE OP_IF +/// "ord" +/// OP_1 OP_1 +/// OP_0 ... +/// OP_ENDIF +/// ``` +/// +/// # Arguments +/// * `internal_key` - The x-only public key for the taproot output +/// * `content_type` - MIME type of the inscription (e.g., "text/plain", "image/png") +/// * `data` - The inscription data +/// +/// # Returns +/// A compiled Bitcoin script containing the inscription +pub fn build_inscription_script( + internal_key: &XOnlyPublicKey, + content_type: &str, + data: &[u8], +) -> ScriptBuf { + let mut builder = Builder::new(); + + // OP_CHECKSIG + builder = builder.push_x_only_key(internal_key); + builder = builder.push_opcode(OP_CHECKSIG); + + // OP_FALSE OP_IF (start inscription envelope) + builder = builder.push_opcode(OP_FALSE); + builder = builder.push_opcode(OP_IF); + + // "ord" - protocol identifier + let ord_bytes = PushBytesBuf::try_from(b"ord".to_vec()).expect("ord is 3 bytes"); + builder = builder.push_slice(ord_bytes); + + // OP_1 OP_1 - content type tag + // Note: The ordinals decoder has a quirk where it expects two separate OP_1s + // instead of a single OP_PUSHNUM_1 + builder = builder.push_opcode(OP_PUSHNUM_1); + builder = builder.push_opcode(OP_PUSHNUM_1); + + // + let content_type_bytes = + PushBytesBuf::try_from(content_type.as_bytes().to_vec()).expect("content type too long"); + builder = builder.push_slice(content_type_bytes); + + // OP_0 - body tag + builder = builder.push_opcode(OP_PUSHBYTES_0); + + // Data chunks (split into MAX_PUSH_SIZE byte chunks) + for chunk in split_into_chunks(data) { + let chunk_bytes = PushBytesBuf::try_from(chunk.to_vec()).expect("chunk is <= 520 bytes"); + builder = builder.push_slice(chunk_bytes); + } + + // OP_ENDIF (end inscription envelope) + builder = builder.push_opcode(OP_ENDIF); + + builder.into_script() +} + +#[cfg(test)] +mod tests { + use super::*; + use miniscript::bitcoin::hashes::Hash; + use miniscript::bitcoin::secp256k1::{Secp256k1, SecretKey}; + use miniscript::bitcoin::XOnlyPublicKey; + + fn test_pubkey() -> XOnlyPublicKey { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1u8; 32]).expect("32 bytes, within curve order"); + let (xonly, _parity) = secret_key.x_only_public_key(&secp); + xonly + } + + #[test] + fn test_build_inscription_script_simple() { + let pubkey = test_pubkey(); + let script = build_inscription_script(&pubkey, "text/plain", b"Hello, World!"); + + // Verify the script contains expected elements + let script_bytes = script.as_bytes(); + assert!(script_bytes.len() > 50); // Should have reasonable length + + // Check for "ord" in the script + let script_hex = hex::encode(script_bytes); + assert!(script_hex.contains(&hex::encode(b"ord"))); + + // Check for content type + assert!(script_hex.contains(&hex::encode(b"text/plain"))); + + // Check for data + assert!(script_hex.contains(&hex::encode(b"Hello, World!"))); + } + + #[test] + fn test_build_inscription_script_large_data() { + let pubkey = test_pubkey(); + // Create data larger than MAX_PUSH_SIZE + let large_data = vec![0xABu8; 1000]; + let script = build_inscription_script(&pubkey, "application/octet-stream", &large_data); + + // Script should be created successfully + assert!(script.as_bytes().len() > 1000); + } +} diff --git a/packages/wasm-utxo/src/inscriptions/mod.rs b/packages/wasm-utxo/src/inscriptions/mod.rs new file mode 100644 index 0000000..71ec6b2 --- /dev/null +++ b/packages/wasm-utxo/src/inscriptions/mod.rs @@ -0,0 +1,14 @@ +//! Inscription support for Bitcoin Ordinals +//! +//! This module provides functionality for creating and signing inscription +//! reveal transactions following the Ordinals protocol. +//! +//! See: https://docs.ordinals.com/inscriptions.html + +mod envelope; +mod reveal; + +pub use envelope::build_inscription_script; +pub use reveal::{ + create_inscription_reveal_data, sign_reveal_transaction, InscriptionRevealData, TapLeafScript, +}; diff --git a/packages/wasm-utxo/src/inscriptions/reveal.rs b/packages/wasm-utxo/src/inscriptions/reveal.rs new file mode 100644 index 0000000..4c4d969 --- /dev/null +++ b/packages/wasm-utxo/src/inscriptions/reveal.rs @@ -0,0 +1,250 @@ +//! Inscription reveal transaction creation and signing +//! +//! Handles creating P2TR outputs with inscription scripts and signing +//! reveal transactions using taproot script path spending. + +use super::envelope::build_inscription_script; +use crate::error::WasmUtxoError; +use miniscript::bitcoin::hashes::Hash; +use miniscript::bitcoin::key::UntweakedKeypair; +use miniscript::bitcoin::psbt::Psbt; +use miniscript::bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey}; +use miniscript::bitcoin::sighash::{Prevouts, SighashCache}; +use miniscript::bitcoin::taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder}; +use miniscript::bitcoin::{ScriptBuf, Transaction, TxOut, Witness}; + +/// Taproot leaf script data needed for spending +#[derive(Debug, Clone)] +pub struct TapLeafScript { + pub leaf_version: u8, + pub script: Vec, + pub control_block: Vec, +} + +/// Prepared data for an inscription reveal transaction +#[derive(Debug, Clone)] +pub struct InscriptionRevealData { + /// The P2TR output script for the commit transaction (network-agnostic) + pub output_script: Vec, + pub reveal_transaction_vsize: usize, + pub tap_leaf_script: TapLeafScript, +} + +/// Create inscription reveal data including the commit output script and tap leaf script +/// +/// # Arguments +/// * `internal_key` - The x-only public key (32 bytes) +/// * `content_type` - MIME type of the inscription +/// * `data` - The inscription data +/// +/// # Returns +/// `InscriptionRevealData` containing the commit output script, estimated vsize, and tap leaf script +pub fn create_inscription_reveal_data( + internal_key: &XOnlyPublicKey, + content_type: &str, + data: &[u8], +) -> Result { + let secp = Secp256k1::new(); + + // Build the inscription script + let script = build_inscription_script(internal_key, content_type, data); + + // Create taproot tree with the inscription script as the only leaf + let builder = TaprootBuilder::new() + .add_leaf(0, script.clone()) + .map_err(|e| WasmUtxoError::new(&format!("Failed to build taproot tree: {:?}", e)))?; + + // Finalize the taproot spend info + // Use an unspendable internal key (all zeros XOR'd with script root) + // For simplicity, we use the provided internal_key + let spend_info = builder + .finalize(&secp, *internal_key) + .map_err(|e| WasmUtxoError::new(&format!("Failed to finalize taproot: {:?}", e)))?; + + // Get the output script (network-agnostic) + let output_script = ScriptBuf::new_p2tr_tweaked(spend_info.output_key()); + + // Get the control block for the script + let control_block = spend_info + .control_block(&(script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| WasmUtxoError::new("Failed to get control block"))?; + + // Estimate reveal transaction vsize + let reveal_vsize = estimate_reveal_vsize(&script, &control_block); + + Ok(InscriptionRevealData { + output_script: output_script.to_bytes(), + reveal_transaction_vsize: reveal_vsize, + tap_leaf_script: TapLeafScript { + leaf_version: LeafVersion::TapScript.to_consensus() as u8, + script: script.to_bytes(), + control_block: control_block.serialize(), + }, + }) +} + +/// Sign a reveal transaction +/// +/// # Arguments +/// * `private_key` - The private key (32 bytes) +/// * `tap_leaf_script` - The tap leaf script from `create_inscription_reveal_data` +/// * `commit_tx` - The commit transaction +/// * `commit_output_script` - The commit output script (P2TR) +/// * `recipient_output_script` - Where to send the inscription (output script) +/// * `output_value_sats` - Value in satoshis for the inscription output +/// +/// # Returns +/// A signed PSBT containing the reveal transaction +pub fn sign_reveal_transaction( + private_key: &SecretKey, + tap_leaf_script: &TapLeafScript, + commit_tx: &Transaction, + commit_output_script: &[u8], + recipient_output_script: &[u8], + output_value_sats: u64, +) -> Result { + let secp = Secp256k1::new(); + + // Convert output scripts + let commit_script = ScriptBuf::from_bytes(commit_output_script.to_vec()); + let recipient_script = ScriptBuf::from_bytes(recipient_output_script.to_vec()); + + // Find the commit output (must be exactly one) + let matching_outputs: Vec<_> = commit_tx + .output + .iter() + .enumerate() + .filter(|(_, out)| out.script_pubkey == commit_script) + .collect(); + + let (vout, commit_output) = match matching_outputs.len() { + 0 => return Err(WasmUtxoError::new("Commit output not found in transaction")), + 1 => matching_outputs[0], + n => { + return Err(WasmUtxoError::new(&format!( + "Expected exactly one commit output, found {}", + n + ))) + } + }; + + // Parse tap leaf script + let script = ScriptBuf::from_bytes(tap_leaf_script.script.clone()); + let control_block = ControlBlock::decode(&tap_leaf_script.control_block) + .map_err(|e| WasmUtxoError::new(&format!("Invalid control block: {:?}", e)))?; + + // Create the reveal transaction + let reveal_input = miniscript::bitcoin::TxIn { + previous_output: miniscript::bitcoin::OutPoint { + txid: commit_tx.compute_txid(), + vout: vout as u32, + }, + script_sig: ScriptBuf::new(), + sequence: miniscript::bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }; + + let reveal_output = TxOut { + value: miniscript::bitcoin::Amount::from_sat(output_value_sats), + script_pubkey: recipient_script, + }; + + let mut reveal_tx = Transaction { + version: miniscript::bitcoin::transaction::Version::TWO, + lock_time: miniscript::bitcoin::absolute::LockTime::ZERO, + input: vec![reveal_input], + output: vec![reveal_output], + }; + + // Sign the transaction using taproot script path + let leaf_hash = TapLeafHash::from_script(&script, LeafVersion::TapScript); + let prevouts = vec![commit_output.clone()]; + + let mut sighash_cache = SighashCache::new(&reveal_tx); + let sighash = sighash_cache + .taproot_script_spend_signature_hash( + 0, + &Prevouts::All(&prevouts), + leaf_hash, + miniscript::bitcoin::TapSighashType::Default, + ) + .map_err(|e| WasmUtxoError::new(&format!("Failed to compute sighash: {}", e)))?; + + // Sign + let keypair = UntweakedKeypair::from_secret_key(&secp, private_key); + let message = miniscript::bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array()); + let signature = secp.sign_schnorr_no_aux_rand(&message, &keypair); + + // Build witness: