Skip to content

Commit 7567b00

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 7567b00

File tree

5 files changed

+477
-35
lines changed

5 files changed

+477
-35
lines changed

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: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,35 @@
55
66
use super::envelope::build_inscription_script;
77
use crate::error::WasmUtxoError;
8+
use miniscript::bitcoin::consensus::Encodable;
9+
use miniscript::bitcoin::hashes::sha256;
810
use miniscript::bitcoin::hashes::Hash;
911
use miniscript::bitcoin::key::UntweakedKeypair;
10-
use miniscript::bitcoin::psbt::Psbt;
11-
use miniscript::bitcoin::secp256k1::{Secp256k1, SecretKey, XOnlyPublicKey};
12+
use miniscript::bitcoin::secp256k1::{PublicKey, 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+
fn nums_point() -> XOnlyPublicKey {
22+
let secp = Secp256k1::new();
23+
// Generator point G is the public key for secret key = 1
24+
let one = SecretKey::from_slice(&[
25+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
26+
0, 1,
27+
])
28+
.expect("valid secret key");
29+
let generator = PublicKey::from_secret_key(&secp, &one);
30+
// Uncompressed format: 04 || x || y (65 bytes)
31+
let uncompressed = generator.serialize_uncompressed();
32+
// SHA256 hash of uncompressed generator point
33+
let hash = sha256::Hash::hash(&uncompressed);
34+
XOnlyPublicKey::from_slice(hash.as_ref()).expect("valid x-only pubkey")
35+
}
36+
1637
/// Taproot leaf script data needed for spending
1738
#[derive(Debug, Clone)]
1839
pub struct TapLeafScript {
@@ -33,32 +54,32 @@ pub struct InscriptionRevealData {
3354
/// Create inscription reveal data including the commit output script and tap leaf script
3455
///
3556
/// # Arguments
36-
/// * `internal_key` - The x-only public key (32 bytes)
57+
/// * `script_pubkey` - The x-only public key for the OP_CHECKSIG in the inscription script
3758
/// * `content_type` - MIME type of the inscription
3859
/// * `data` - The inscription data
3960
///
4061
/// # Returns
4162
/// `InscriptionRevealData` containing the commit output script, estimated vsize, and tap leaf script
4263
pub fn create_inscription_reveal_data(
43-
internal_key: &XOnlyPublicKey,
64+
script_pubkey: &XOnlyPublicKey,
4465
content_type: &str,
4566
data: &[u8],
4667
) -> Result<InscriptionRevealData, WasmUtxoError> {
4768
let secp = Secp256k1::new();
4869

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

5273
// Create taproot tree with the inscription script as the only leaf
5374
let builder = TaprootBuilder::new()
5475
.add_leaf(0, script.clone())
5576
.map_err(|e| WasmUtxoError::new(&format!("Failed to build taproot tree: {:?}", e)))?;
5677

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
78+
// Use NUMS point as internal key (disables key-path spending)
79+
// This matches utxo-lib's behavior for compatibility
80+
let internal_key = nums_point();
6081
let spend_info = builder
61-
.finalize(&secp, *internal_key)
82+
.finalize(&secp, internal_key)
6283
.map_err(|e| WasmUtxoError::new(&format!("Failed to finalize taproot: {:?}", e)))?;
6384

6485
// Get the output script (network-agnostic)
@@ -94,15 +115,15 @@ pub fn create_inscription_reveal_data(
94115
/// * `output_value_sats` - Value in satoshis for the inscription output
95116
///
96117
/// # Returns
97-
/// A signed PSBT containing the reveal transaction
118+
/// The signed reveal transaction as bytes (ready to broadcast)
98119
pub fn sign_reveal_transaction(
99120
private_key: &SecretKey,
100121
tap_leaf_script: &TapLeafScript,
101122
commit_tx: &Transaction,
102123
commit_output_script: &[u8],
103124
recipient_output_script: &[u8],
104125
output_value_sats: u64,
105-
) -> Result<Psbt, WasmUtxoError> {
126+
) -> Result<Vec<u8>, WasmUtxoError> {
106127
let secp = Secp256k1::new();
107128

108129
// Convert output scripts
@@ -187,14 +208,13 @@ pub fn sign_reveal_transaction(
187208
witness.push(control_block.serialize());
188209
reveal_tx.input[0].witness = witness;
189210

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)))?;
211+
// Serialize the signed transaction
212+
let mut tx_bytes = Vec::new();
213+
reveal_tx
214+
.consensus_encode(&mut tx_bytes)
215+
.map_err(|e| WasmUtxoError::new(&format!("Failed to serialize transaction: {}", e)))?;
193216

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)
217+
Ok(tx_bytes)
198218
}
199219

200220
/// Estimate the virtual size of a reveal transaction
@@ -224,6 +244,7 @@ fn estimate_reveal_vsize(script: &ScriptBuf, control_block: &ControlBlock) -> us
224244
#[cfg(test)]
225245
mod tests {
226246
use super::*;
247+
use miniscript::bitcoin::hashes::hex::FromHex;
227248

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

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)