Skip to content

Commit a09ebe8

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): fix ordinal inscriptions protocol compatibility
Updates the inscription envelope format to match the ordinals protocol specification. The content type tag is now correctly encoded as a byte value (0x01) instead of using OP_1. Also switches to using a NUMS point for the taproot internal key to ensure compatibility with other implementations. The signRevealTransaction function now returns raw transaction bytes instead of a PSBT for simpler broadcast. Adds comprehensive tests to verify compatibility with utxo-lib's ordinals implementation. Issue: BTC-2936 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent de6894d commit a09ebe8

5 files changed

Lines changed: 467 additions & 34 deletions

File tree

packages/wasm-utxo/js/inscriptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@ export function createInscriptionRevealData(
8989
* @param commitOutputScript - The commit output script (P2TR)
9090
* @param recipientOutputScript - Where to send the inscription (output script)
9191
* @param outputValueSats - Value in satoshis for the inscription output
92-
* @returns The signed PSBT as bytes
92+
* @returns The signed transaction as bytes (ready to broadcast)
9393
*
9494
* @example
9595
* ```typescript
96-
* const psbtBytes = signRevealTransaction(
96+
* const txBytes = signRevealTransaction(
9797
* privateKey,
9898
* revealData.tapLeafScript,
9999
* commitTx,

packages/wasm-utxo/src/inscriptions/envelope.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
//! Creates the taproot script containing the inscription data following
44
//! the Ordinals protocol format.
55
6-
use miniscript::bitcoin::opcodes::all::{
7-
OP_CHECKSIG, OP_ENDIF, OP_IF, OP_PUSHBYTES_0, OP_PUSHNUM_1,
8-
};
6+
use miniscript::bitcoin::opcodes::all::{OP_CHECKSIG, OP_ENDIF, OP_IF, OP_PUSHBYTES_0};
97
use miniscript::bitcoin::opcodes::OP_FALSE;
108
use miniscript::bitcoin::script::{Builder, PushBytesBuf};
119
use miniscript::bitcoin::secp256k1::XOnlyPublicKey;
@@ -56,11 +54,10 @@ pub fn build_inscription_script(
5654
let ord_bytes = PushBytesBuf::try_from(b"ord".to_vec()).expect("ord is 3 bytes");
5755
builder = builder.push_slice(ord_bytes);
5856

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);
57+
// Content type tag: push byte 0x01 (tag number for content-type)
58+
// Encoded as PUSHBYTES_1 0x01 (two bytes: 01 01)
59+
let tag_content_type = PushBytesBuf::try_from(vec![0x01]).expect("single byte");
60+
builder = builder.push_slice(tag_content_type);
6461

6562
// <content_type>
6663
let content_type_bytes =

packages/wasm-utxo/src/inscriptions/reveal.rs

Lines changed: 149 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,26 @@
55
66
use super::envelope::build_inscription_script;
77
use crate::error::WasmUtxoError;
8+
use miniscript::bitcoin::consensus::Encodable;
9+
use miniscript::bitcoin::hashes::hex::FromHex;
810
use miniscript::bitcoin::hashes::Hash;
911
use miniscript::bitcoin::key::UntweakedKeypair;
10-
use miniscript::bitcoin::psbt::Psbt;
1112
use miniscript::bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey};
1213
use miniscript::bitcoin::sighash::{Prevouts, SighashCache};
1314
use miniscript::bitcoin::taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder};
1415
use miniscript::bitcoin::{ScriptBuf, Transaction, TxOut, Witness};
1516

17+
/// NUMS point (Nothing Up My Sleeve) - a secp256k1 x coordinate with unknown discrete logarithm.
18+
/// Equal to SHA256(uncompressedDER(SECP256K1_GENERATOR_POINT)).
19+
/// Used as internal key when key-path spending is disabled.
20+
/// This matches utxo-lib's implementation for compatibility.
21+
const NUMS_POINT_HEX: &str = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0";
22+
23+
fn nums_point() -> XOnlyPublicKey {
24+
let bytes = Vec::<u8>::from_hex(NUMS_POINT_HEX).expect("valid hex");
25+
XOnlyPublicKey::from_slice(&bytes).expect("valid x-only pubkey")
26+
}
27+
1628
/// Taproot leaf script data needed for spending
1729
#[derive(Debug, Clone)]
1830
pub struct TapLeafScript {
@@ -33,32 +45,32 @@ pub struct InscriptionRevealData {
3345
/// Create inscription reveal data including the commit output script and tap leaf script
3446
///
3547
/// # Arguments
36-
/// * `internal_key` - The x-only public key (32 bytes)
48+
/// * `script_pubkey` - The x-only public key for the OP_CHECKSIG in the inscription script
3749
/// * `content_type` - MIME type of the inscription
3850
/// * `data` - The inscription data
3951
///
4052
/// # Returns
4153
/// `InscriptionRevealData` containing the commit output script, estimated vsize, and tap leaf script
4254
pub fn create_inscription_reveal_data(
43-
internal_key: &XOnlyPublicKey,
55+
script_pubkey: &XOnlyPublicKey,
4456
content_type: &str,
4557
data: &[u8],
4658
) -> Result<InscriptionRevealData, WasmUtxoError> {
4759
let secp = Secp256k1::new();
4860

49-
// Build the inscription script
50-
let script = build_inscription_script(internal_key, content_type, data);
61+
// Build the inscription script (pubkey is used for OP_CHECKSIG inside the script)
62+
let script = build_inscription_script(script_pubkey, content_type, data);
5163

5264
// Create taproot tree with the inscription script as the only leaf
5365
let builder = TaprootBuilder::new()
5466
.add_leaf(0, script.clone())
5567
.map_err(|e| WasmUtxoError::new(&format!("Failed to build taproot tree: {:?}", e)))?;
5668

57-
// Finalize the taproot spend info
58-
// Use an unspendable internal key (all zeros XOR'd with script root)
59-
// For simplicity, we use the provided internal_key
69+
// Use NUMS point as internal key (disables key-path spending)
70+
// This matches utxo-lib's behavior for compatibility
71+
let internal_key = nums_point();
6072
let spend_info = builder
61-
.finalize(&secp, *internal_key)
73+
.finalize(&secp, internal_key)
6274
.map_err(|e| WasmUtxoError::new(&format!("Failed to finalize taproot: {:?}", e)))?;
6375

6476
// Get the output script (network-agnostic)
@@ -94,15 +106,15 @@ pub fn create_inscription_reveal_data(
94106
/// * `output_value_sats` - Value in satoshis for the inscription output
95107
///
96108
/// # Returns
97-
/// A signed PSBT containing the reveal transaction
109+
/// The signed reveal transaction as bytes (ready to broadcast)
98110
pub fn sign_reveal_transaction(
99111
private_key: &SecretKey,
100112
tap_leaf_script: &TapLeafScript,
101113
commit_tx: &Transaction,
102114
commit_output_script: &[u8],
103115
recipient_output_script: &[u8],
104116
output_value_sats: u64,
105-
) -> Result<Psbt, WasmUtxoError> {
117+
) -> Result<Vec<u8>, WasmUtxoError> {
106118
let secp = Secp256k1::new();
107119

108120
// Convert output scripts
@@ -187,14 +199,13 @@ pub fn sign_reveal_transaction(
187199
witness.push(control_block.serialize());
188200
reveal_tx.input[0].witness = witness;
189201

190-
// Create PSBT from finalized transaction
191-
let psbt = Psbt::from_unsigned_tx(reveal_tx.clone())
192-
.map_err(|e| WasmUtxoError::new(&format!("Failed to create PSBT: {}", e)))?;
202+
// Serialize the signed transaction
203+
let mut tx_bytes = Vec::new();
204+
reveal_tx
205+
.consensus_encode(&mut tx_bytes)
206+
.map_err(|e| WasmUtxoError::new(&format!("Failed to serialize transaction: {}", e)))?;
193207

194-
// Note: The PSBT is created from the signed transaction for compatibility
195-
// with the expected return type. In practice, this is already finalized.
196-
197-
Ok(psbt)
208+
Ok(tx_bytes)
198209
}
199210

200211
/// Estimate the virtual size of a reveal transaction
@@ -224,6 +235,7 @@ fn estimate_reveal_vsize(script: &ScriptBuf, control_block: &ControlBlock) -> us
224235
#[cfg(test)]
225236
mod tests {
226237
use super::*;
238+
use miniscript::bitcoin::hashes::hex::FromHex;
227239

228240
fn test_keypair() -> (SecretKey, XOnlyPublicKey) {
229241
let secp = Secp256k1::new();
@@ -247,4 +259,123 @@ mod tests {
247259
assert!(!data.tap_leaf_script.script.is_empty());
248260
assert!(!data.tap_leaf_script.control_block.is_empty());
249261
}
262+
263+
/// Test with the same x-only pubkey as utxo-ord test
264+
/// Expected output script: 5120dc8b12eec336e7215fd1213acf66fb0d5dd962813c0616988a12c08493831109
265+
/// Expected address: tb1pmj939mkrxmnjzh73yyav7ehmp4wajc5p8srpdxy2ztqgfyurzyys4sg9zx
266+
#[test]
267+
fn test_utxo_ord_fixture_short_data() {
268+
// Same x-only pubkey as utxo-ord test
269+
let xonly_hex = "af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3";
270+
let xonly_bytes = Vec::<u8>::from_hex(xonly_hex).unwrap();
271+
let pubkey = XOnlyPublicKey::from_slice(&xonly_bytes).unwrap();
272+
273+
let inscription_data = b"Never Gonna Give You Up";
274+
let result = create_inscription_reveal_data(&pubkey, "text/plain", inscription_data);
275+
276+
assert!(result.is_ok());
277+
let data = result.unwrap();
278+
279+
// Log the actual output for debugging
280+
let output_hex = hex::encode(&data.output_script);
281+
println!("X-only pubkey: {}", xonly_hex);
282+
println!(
283+
"Inscription data: {:?}",
284+
String::from_utf8_lossy(inscription_data)
285+
);
286+
println!("Output script (actual): {}", output_hex);
287+
println!("Output script (expected): 5120dc8b12eec336e7215fd1213acf66fb0d5dd962813c0616988a12c08493831109");
288+
289+
// Log the tap leaf script for debugging
290+
println!(
291+
"Tap leaf script hex: {}",
292+
hex::encode(&data.tap_leaf_script.script)
293+
);
294+
println!(
295+
"Control block hex: {}",
296+
hex::encode(&data.tap_leaf_script.control_block)
297+
);
298+
299+
// Basic structure checks
300+
assert_eq!(data.output_script.len(), 34);
301+
assert_eq!(data.output_script[0], 0x51); // OP_1
302+
assert_eq!(data.output_script[1], 0x20); // PUSH32
303+
304+
// Assert byte-exact match with utxo-ord
305+
let expected_hex = "5120dc8b12eec336e7215fd1213acf66fb0d5dd962813c0616988a12c08493831109";
306+
assert_eq!(
307+
output_hex, expected_hex,
308+
"Output script should match utxo-ord fixture"
309+
);
310+
}
311+
312+
/// Test with large data (>520 bytes) - same as utxo-ord test
313+
/// Expected output script: 5120ec90ba87f3e7c5462eb2173afdc50e00cea6fc69166677171d70f45dfb3a31b8
314+
/// Expected address: tb1pajgt4plnulz5vt4jzua0m3gwqr82dlrfzen8w9cawr69m7e6xxuq7dzypl
315+
#[test]
316+
fn test_utxo_ord_fixture_large_data() {
317+
let xonly_hex = "af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3";
318+
let xonly_bytes = Vec::<u8>::from_hex(xonly_hex).unwrap();
319+
let pubkey = XOnlyPublicKey::from_slice(&xonly_bytes).unwrap();
320+
321+
// "Never Gonna Let You Down" repeated 100 times
322+
let base = b"Never Gonna Let You Down";
323+
let inscription_data: Vec<u8> = base
324+
.iter()
325+
.cycle()
326+
.take(base.len() * 100)
327+
.copied()
328+
.collect();
329+
330+
let result = create_inscription_reveal_data(&pubkey, "text/plain", &inscription_data);
331+
332+
assert!(result.is_ok());
333+
let data = result.unwrap();
334+
335+
let output_hex = hex::encode(&data.output_script);
336+
println!("Output script (actual): {}", output_hex);
337+
println!("Output script (expected): 5120ec90ba87f3e7c5462eb2173afdc50e00cea6fc69166677171d70f45dfb3a31b8");
338+
339+
assert_eq!(data.output_script.len(), 34);
340+
assert_eq!(data.output_script[0], 0x51);
341+
assert_eq!(data.output_script[1], 0x20);
342+
343+
// Assert byte-exact match with utxo-ord
344+
let expected_hex = "5120ec90ba87f3e7c5462eb2173afdc50e00cea6fc69166677171d70f45dfb3a31b8";
345+
assert_eq!(
346+
output_hex, expected_hex,
347+
"Output script should match utxo-ord fixture"
348+
);
349+
}
350+
351+
/// Debug test to understand taproot key tweaking
352+
#[test]
353+
fn test_taproot_tweak_details() {
354+
let xonly_hex = "af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3";
355+
let xonly_bytes = Vec::<u8>::from_hex(xonly_hex).unwrap();
356+
let internal_key = XOnlyPublicKey::from_slice(&xonly_bytes).unwrap();
357+
358+
println!("Internal key: {}", internal_key);
359+
360+
let secp = Secp256k1::new();
361+
let script =
362+
build_inscription_script(&internal_key, "text/plain", b"Never Gonna Give You Up");
363+
364+
println!("Inscription script hex: {}", hex::encode(script.as_bytes()));
365+
println!("Inscription script len: {}", script.len());
366+
367+
// Build taproot tree
368+
let builder = TaprootBuilder::new().add_leaf(0, script.clone()).unwrap();
369+
370+
let spend_info = builder.finalize(&secp, internal_key).unwrap();
371+
372+
println!("Output key (tweaked): {}", spend_info.output_key());
373+
println!(
374+
"Merkle root: {:?}",
375+
spend_info.merkle_root().map(|r| r.to_string())
376+
);
377+
378+
let output_script = ScriptBuf::new_p2tr_tweaked(spend_info.output_key());
379+
println!("Output script: {}", hex::encode(output_script.as_bytes()));
380+
}
250381
}

packages/wasm-utxo/src/wasm/inscriptions.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,14 @@ impl InscriptionsNamespace {
8585
// Parse the tap leaf script from JS using TryFromJsValue trait
8686
let tap_leaf = TapLeafScript::try_from_js_value(&tap_leaf_script)?;
8787

88-
// Sign the reveal transaction
89-
let psbt = sign_reveal_impl(
88+
// Sign the reveal transaction and return bytes
89+
sign_reveal_impl(
9090
&secret_key,
9191
&tap_leaf,
9292
&commit_tx.tx,
9393
commit_output_script,
9494
recipient_output_script,
9595
output_value_sats,
96-
)?;
97-
98-
// Serialize to bytes
99-
Ok(psbt.serialize())
96+
)
10097
}
10198
}

0 commit comments

Comments
 (0)