Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/wasm-utxo/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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

Expand Down
130 changes: 130 additions & 0 deletions packages/wasm-utxo/js/inscriptions.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
129 changes: 129 additions & 0 deletions packages/wasm-utxo/src/inscriptions/envelope.rs
Original file line number Diff line number Diff line change
@@ -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
/// <pubkey> OP_CHECKSIG OP_FALSE OP_IF
/// "ord"
/// OP_1 OP_1 <content_type>
/// OP_0 <data_chunk_1> <data_chunk_2> ...
/// 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();

// <pubkey> 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);

// <content_type>
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);
}
}
14 changes: 14 additions & 0 deletions packages/wasm-utxo/src/inscriptions/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Loading