Skip to content

Commit d1d4ce7

Browse files
Merge pull request #94 from BitGo/BTC-2936.add-inscription-support
feat(wasm-utxo): add Bitcoin Ordinals inscription support
2 parents 1d9072d + 9f7c210 commit d1d4ce7

10 files changed

Lines changed: 663 additions & 0 deletions

File tree

packages/wasm-utxo/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ void wasm;
99
export * as address from "./address.js";
1010
export * as ast from "./ast/index.js";
1111
export * as bip322 from "./bip322/index.js";
12+
export * as inscriptions from "./inscriptions.js";
1213
export * as utxolibCompat from "./utxolibCompat.js";
1314
export * as fixedScriptWallet from "./fixedScriptWallet/index.js";
1415
export * as bip32 from "./bip32.js";
@@ -22,6 +23,7 @@ export { Dimensions } from "./fixedScriptWallet/Dimensions.js";
2223
export type { CoinName } from "./coinName.js";
2324
export type { Triple } from "./triple.js";
2425
export type { AddressFormat } from "./address.js";
26+
export type { TapLeafScript, PreparedInscriptionRevealData } from "./inscriptions.js";
2527

2628
// TODO: the exports below should be namespaced under `descriptor` in the future
2729

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Inscription support for Bitcoin Ordinals
3+
*
4+
* This module provides functionality for creating and signing inscription
5+
* reveal transactions following the Ordinals protocol.
6+
*
7+
* @see https://docs.ordinals.com/inscriptions.html
8+
*/
9+
10+
import { InscriptionsNamespace } from "./wasm/wasm_utxo.js";
11+
import { Transaction } from "./transaction.js";
12+
import { type ECPairArg, ECPair } from "./ecpair.js";
13+
14+
/**
15+
* Taproot leaf script data needed for spending
16+
*/
17+
export type TapLeafScript = {
18+
/** Leaf version (typically 0xc0 for TapScript) */
19+
leafVersion: number;
20+
/** The compiled script bytes */
21+
script: Uint8Array;
22+
/** Control block for script path spending */
23+
controlBlock: Uint8Array;
24+
};
25+
26+
/**
27+
* Prepared data for an inscription reveal transaction
28+
*/
29+
export type PreparedInscriptionRevealData = {
30+
/** The commit output script (P2TR, network-agnostic) */
31+
outputScript: Uint8Array;
32+
/** Estimated virtual size of the reveal transaction */
33+
revealTransactionVSize: number;
34+
/** Tap leaf script for spending the commit output */
35+
tapLeafScript: TapLeafScript;
36+
};
37+
38+
/**
39+
* Create inscription reveal data including the commit output script and tap leaf script
40+
*
41+
* This function creates all the data needed to perform an inscription:
42+
* 1. A P2TR output script for the commit transaction (network-agnostic)
43+
* 2. The tap leaf script needed to spend from that output
44+
* 3. An estimate of the reveal transaction's virtual size for fee calculation
45+
*
46+
* @param key - The key pair (ECPairArg: Uint8Array, ECPair, or WasmECPair). The x-only public key will be extracted.
47+
* @param contentType - MIME type of the inscription (e.g., "text/plain", "image/png")
48+
* @param inscriptionData - The inscription data bytes
49+
* @returns PreparedInscriptionRevealData containing output script, vsize estimate, and tap leaf script
50+
*
51+
* @example
52+
* ```typescript
53+
* const revealData = createInscriptionRevealData(
54+
* ecpair,
55+
* "text/plain",
56+
* new TextEncoder().encode("Hello, Ordinals!"),
57+
* );
58+
* // Use address.fromOutputScriptWithCoin() to get address for a specific network
59+
* console.log(`Estimated reveal vsize: ${revealData.revealTransactionVSize}`);
60+
* ```
61+
*/
62+
export function createInscriptionRevealData(
63+
key: ECPairArg,
64+
contentType: string,
65+
inscriptionData: Uint8Array,
66+
): PreparedInscriptionRevealData {
67+
// Convert to ECPair and extract x-only public key (strip parity byte from compressed pubkey)
68+
const ecpair = ECPair.from(key);
69+
const compressedPubkey = ecpair.publicKey;
70+
const xOnlyPubkey = compressedPubkey.slice(1); // Remove first byte (parity)
71+
72+
// Call snake_case WASM method (traits output camelCase)
73+
return InscriptionsNamespace.create_inscription_reveal_data(
74+
xOnlyPubkey,
75+
contentType,
76+
inscriptionData,
77+
) as PreparedInscriptionRevealData;
78+
}
79+
80+
/**
81+
* Sign a reveal transaction
82+
*
83+
* Creates and signs the reveal transaction that spends from the commit output
84+
* and sends the inscription to the recipient.
85+
*
86+
* @param key - The private key (ECPairArg: Uint8Array, ECPair, or WasmECPair)
87+
* @param tapLeafScript - The tap leaf script from createInscriptionRevealData
88+
* @param commitTx - The commit transaction
89+
* @param commitOutputScript - The commit output script (P2TR)
90+
* @param recipientOutputScript - Where to send the inscription (output script)
91+
* @param outputValueSats - Value in satoshis for the inscription output
92+
* @returns The signed PSBT as bytes
93+
*
94+
* @example
95+
* ```typescript
96+
* const psbtBytes = signRevealTransaction(
97+
* privateKey,
98+
* revealData.tapLeafScript,
99+
* commitTx,
100+
* revealData.outputScript,
101+
* recipientOutputScript,
102+
* 10000n, // 10,000 sats
103+
* );
104+
* ```
105+
*/
106+
export function signRevealTransaction(
107+
key: ECPairArg,
108+
tapLeafScript: TapLeafScript,
109+
commitTx: Transaction,
110+
commitOutputScript: Uint8Array,
111+
recipientOutputScript: Uint8Array,
112+
outputValueSats: bigint,
113+
): Uint8Array {
114+
// Convert to ECPair to get private key bytes
115+
const ecpair = ECPair.from(key);
116+
const privateKey = ecpair.privateKey;
117+
if (!privateKey) {
118+
throw new Error("ECPair must have a private key for signing");
119+
}
120+
121+
// Call snake_case WASM method
122+
return InscriptionsNamespace.sign_reveal_transaction(
123+
privateKey,
124+
tapLeafScript,
125+
commitTx.wasm,
126+
commitOutputScript,
127+
recipientOutputScript,
128+
outputValueSats,
129+
);
130+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//! Inscription envelope script builder
2+
//!
3+
//! Creates the taproot script containing the inscription data following
4+
//! the Ordinals protocol format.
5+
6+
use miniscript::bitcoin::opcodes::all::{
7+
OP_CHECKSIG, OP_ENDIF, OP_IF, OP_PUSHBYTES_0, OP_PUSHNUM_1,
8+
};
9+
use miniscript::bitcoin::opcodes::OP_FALSE;
10+
use miniscript::bitcoin::script::{Builder, PushBytesBuf};
11+
use miniscript::bitcoin::secp256k1::XOnlyPublicKey;
12+
use miniscript::bitcoin::ScriptBuf;
13+
14+
/// Maximum size of a single data push in tapscript (520 bytes)
15+
const MAX_PUSH_SIZE: usize = 520;
16+
17+
/// Split data into chunks of at most MAX_PUSH_SIZE bytes
18+
fn split_into_chunks(data: &[u8]) -> Vec<&[u8]> {
19+
data.chunks(MAX_PUSH_SIZE).collect()
20+
}
21+
22+
/// Build an inscription envelope script
23+
///
24+
/// The script follows the Ordinals protocol format:
25+
/// ```text
26+
/// <pubkey> OP_CHECKSIG OP_FALSE OP_IF
27+
/// "ord"
28+
/// OP_1 OP_1 <content_type>
29+
/// OP_0 <data_chunk_1> <data_chunk_2> ...
30+
/// OP_ENDIF
31+
/// ```
32+
///
33+
/// # Arguments
34+
/// * `internal_key` - The x-only public key for the taproot output
35+
/// * `content_type` - MIME type of the inscription (e.g., "text/plain", "image/png")
36+
/// * `data` - The inscription data
37+
///
38+
/// # Returns
39+
/// A compiled Bitcoin script containing the inscription
40+
pub fn build_inscription_script(
41+
internal_key: &XOnlyPublicKey,
42+
content_type: &str,
43+
data: &[u8],
44+
) -> ScriptBuf {
45+
let mut builder = Builder::new();
46+
47+
// <pubkey> OP_CHECKSIG
48+
builder = builder.push_x_only_key(internal_key);
49+
builder = builder.push_opcode(OP_CHECKSIG);
50+
51+
// OP_FALSE OP_IF (start inscription envelope)
52+
builder = builder.push_opcode(OP_FALSE);
53+
builder = builder.push_opcode(OP_IF);
54+
55+
// "ord" - protocol identifier
56+
let ord_bytes = PushBytesBuf::try_from(b"ord".to_vec()).expect("ord is 3 bytes");
57+
builder = builder.push_slice(ord_bytes);
58+
59+
// OP_1 OP_1 - content type tag
60+
// Note: The ordinals decoder has a quirk where it expects two separate OP_1s
61+
// instead of a single OP_PUSHNUM_1
62+
builder = builder.push_opcode(OP_PUSHNUM_1);
63+
builder = builder.push_opcode(OP_PUSHNUM_1);
64+
65+
// <content_type>
66+
let content_type_bytes =
67+
PushBytesBuf::try_from(content_type.as_bytes().to_vec()).expect("content type too long");
68+
builder = builder.push_slice(content_type_bytes);
69+
70+
// OP_0 - body tag
71+
builder = builder.push_opcode(OP_PUSHBYTES_0);
72+
73+
// Data chunks (split into MAX_PUSH_SIZE byte chunks)
74+
for chunk in split_into_chunks(data) {
75+
let chunk_bytes = PushBytesBuf::try_from(chunk.to_vec()).expect("chunk is <= 520 bytes");
76+
builder = builder.push_slice(chunk_bytes);
77+
}
78+
79+
// OP_ENDIF (end inscription envelope)
80+
builder = builder.push_opcode(OP_ENDIF);
81+
82+
builder.into_script()
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
use miniscript::bitcoin::hashes::Hash;
89+
use miniscript::bitcoin::secp256k1::{Secp256k1, SecretKey};
90+
use miniscript::bitcoin::XOnlyPublicKey;
91+
92+
fn test_pubkey() -> XOnlyPublicKey {
93+
let secp = Secp256k1::new();
94+
let secret_key = SecretKey::from_slice(&[1u8; 32]).expect("32 bytes, within curve order");
95+
let (xonly, _parity) = secret_key.x_only_public_key(&secp);
96+
xonly
97+
}
98+
99+
#[test]
100+
fn test_build_inscription_script_simple() {
101+
let pubkey = test_pubkey();
102+
let script = build_inscription_script(&pubkey, "text/plain", b"Hello, World!");
103+
104+
// Verify the script contains expected elements
105+
let script_bytes = script.as_bytes();
106+
assert!(script_bytes.len() > 50); // Should have reasonable length
107+
108+
// Check for "ord" in the script
109+
let script_hex = hex::encode(script_bytes);
110+
assert!(script_hex.contains(&hex::encode(b"ord")));
111+
112+
// Check for content type
113+
assert!(script_hex.contains(&hex::encode(b"text/plain")));
114+
115+
// Check for data
116+
assert!(script_hex.contains(&hex::encode(b"Hello, World!")));
117+
}
118+
119+
#[test]
120+
fn test_build_inscription_script_large_data() {
121+
let pubkey = test_pubkey();
122+
// Create data larger than MAX_PUSH_SIZE
123+
let large_data = vec![0xABu8; 1000];
124+
let script = build_inscription_script(&pubkey, "application/octet-stream", &large_data);
125+
126+
// Script should be created successfully
127+
assert!(script.as_bytes().len() > 1000);
128+
}
129+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! Inscription support for Bitcoin Ordinals
2+
//!
3+
//! This module provides functionality for creating and signing inscription
4+
//! reveal transactions following the Ordinals protocol.
5+
//!
6+
//! See: https://docs.ordinals.com/inscriptions.html
7+
8+
mod envelope;
9+
mod reveal;
10+
11+
pub use envelope::build_inscription_script;
12+
pub use reveal::{
13+
create_inscription_reveal_data, sign_reveal_transaction, InscriptionRevealData, TapLeafScript,
14+
};

0 commit comments

Comments
 (0)