Skip to main content

urma_core/
envelope.rs

1use crate::error::{Context, Error, bail, ensure};
2use crate::{
3    container,
4    format::{PublicRecord, RecordKind, Urma},
5    multipart::{Geometry, MultipartRecord},
6};
7use bitcoin::{
8    Script, ScriptBuf, Transaction, TxOut, Witness, XOnlyPublicKey,
9    hashes::Hash,
10    opcodes::all::{OP_CHECKSIG, OP_ENDIF, OP_IF},
11    script::{Builder, Instruction, PushBytesBuf},
12    secp256k1::{Keypair, Message, Secp256k1},
13    sighash::{Prevouts, SighashCache, TapSighashType},
14    taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder, TaprootSpendInfo},
15    transaction::Version,
16};
17
18pub struct ParsedEnvelope {
19    pub author: XOnlyPublicKey,
20    pub record: Vec<u8>,
21    pub script: ScriptBuf,
22    pub control: ControlBlock,
23}
24
25fn validate_record(record: &[u8]) -> Result<(), Error> {
26    match RecordKind::parse(record)? {
27        RecordKind::Private => {
28            container::inspect_header(record)?;
29        }
30        RecordKind::DataPart | RecordKind::LeafManifest | RecordKind::RootManifest => {
31            MultipartRecord::decode(record)?;
32        }
33        RecordKind::Container => bail!("offline container cannot be published as a record"),
34        RecordKind::Post
35        | RecordKind::Reply
36        | RecordKind::Profile
37        | RecordKind::Avatar
38        | RecordKind::WirePost
39        | RecordKind::WireReply => {
40            PublicRecord::decode(record)?;
41        }
42    }
43    Ok(())
44}
45
46fn record_script(record: &[u8], author: XOnlyPublicKey) -> Result<ScriptBuf, Error> {
47    let mut builder = Builder::new()
48        .push_x_only_key(&author)
49        .push_opcode(OP_CHECKSIG)
50        .push_int(0)
51        .push_opcode(OP_IF)
52        .push_slice(Urma::MAGIC);
53    for part in record.chunks(520) {
54        builder = match part {
55            [value @ 1..=16] => builder.push_int(i64::from(*value)),
56            [0x81] => builder.push_int(-1),
57            bytes => builder.push_slice(PushBytesBuf::try_from(bytes.to_vec())?),
58        };
59    }
60    Ok(builder.push_opcode(OP_ENDIF).into_script())
61}
62
63pub fn build(record: &[u8], signer: &Keypair) -> Result<(ScriptBuf, TaprootSpendInfo), Error> {
64    build_for_author(record, signer.x_only_public_key().0)
65}
66
67pub fn build_for_author(
68    record: &[u8],
69    author: XOnlyPublicKey,
70) -> Result<(ScriptBuf, TaprootSpendInfo), Error> {
71    validate_record(record)?;
72    let script = record_script(record, author)?;
73    let info = TaprootBuilder::new()
74        .add_leaf(0, script.clone())?
75        .finalize(&Secp256k1::new(), author)
76        .map_err(|tree| Error::Invalid(format!("invalid single-leaf Taproot tree: {tree:?}")))?;
77    Ok((script, info))
78}
79
80pub fn witness(
81    signature: &[u8],
82    script: &ScriptBuf,
83    info: &TaprootSpendInfo,
84) -> Result<Witness, Error> {
85    ensure!(
86        signature.len() == 64,
87        "URMA requires implicit SIGHASH_DEFAULT"
88    );
89    let control = info
90        .control_block(&(script.clone(), LeafVersion::TapScript))
91        .context("script missing from Taproot tree")?
92        .serialize();
93    ensure!(control.len() == 33, "URMA requires one leaf");
94    Ok(Witness::from_slice(&[
95        signature,
96        script.as_bytes(),
97        &control,
98    ]))
99}
100
101pub fn is_candidate(witness: &Witness) -> bool {
102    if witness.len() != 3 {
103        return false;
104    }
105    let Some(script) = witness.iter().nth(1) else {
106        return false;
107    };
108    script.len() >= 41 && script[34..41] == [0, 99, 4, b'U', b'R', b'M', b'A']
109}
110
111pub fn extract(witness: &Witness) -> Result<ParsedEnvelope, Error> {
112    let parsed = extract_envelope(witness)?;
113    validate_record(&parsed.record)?;
114    Ok(parsed)
115}
116
117fn extract_envelope(witness: &Witness) -> Result<ParsedEnvelope, Error> {
118    ensure!(witness.len() == 3, "invalid witness item count");
119    let items: Vec<_> = witness.iter().collect();
120    ensure!(
121        items[0].len() == 64 && items[2].len() == 33,
122        "invalid signature or control length"
123    );
124    ensure!(
125        items[1].len() >= 42
126            && items[1].len()
127                <= Geometry::RECORD_BYTES + 3 * Geometry::RECORD_BYTES.div_ceil(520) + 42,
128        "script size out of bounds"
129    );
130    let script = ScriptBuf::from_bytes(items[1].to_vec());
131    let author = XOnlyPublicKey::from_slice(&items[1][1..33])?;
132    let control = ControlBlock::decode(items[2])?;
133    ensure!(
134        control.leaf_version == LeafVersion::TapScript && control.internal_key == author,
135        "invalid author/internal-key commitment"
136    );
137    let mut ops = script.instructions_minimal().skip(5);
138    let mut record = Vec::new();
139    loop {
140        let instruction = ops.next().context("missing envelope end")??;
141        match instruction {
142            Instruction::PushBytes(part) => {
143                ensure!(
144                    !part.is_empty() && part.len() <= 520,
145                    "invalid record segment"
146                );
147                record.extend_from_slice(part.as_bytes());
148            }
149            Instruction::Op(opcode) if opcode == OP_ENDIF => {
150                let Some(instruction) = ops.next() else { break };
151                bail!("trailing script instruction: {instruction:?}");
152            }
153            Instruction::Op(opcode) if (0x51..=0x60).contains(&opcode.to_u8()) => {
154                record.push(opcode.to_u8() - 0x50)
155            }
156            Instruction::Op(opcode) if opcode.to_u8() == 0x4f => record.push(0x81),
157            instruction => bail!("invalid envelope instruction {instruction:?}"),
158        }
159        ensure!(record.len() <= Geometry::RECORD_BYTES, "record too large");
160    }
161    ensure!(
162        record_script(&record, author)? == script,
163        "noncanonical envelope or segmentation"
164    );
165    Ok(ParsedEnvelope {
166        author,
167        record,
168        script,
169        control,
170    })
171}
172
173pub fn verify_reveal(reveal: &Transaction, commit: &Transaction) -> Result<ParsedEnvelope, Error> {
174    let parsed = verify_record_proof(reveal, commit)?;
175    validate_record(&parsed.record)?;
176    Ok(parsed)
177}
178
179pub(crate) fn verify_record_proof(
180    reveal: &Transaction,
181    commit: &Transaction,
182) -> Result<ParsedEnvelope, Error> {
183    ensure!(reveal.input.len() == 1, "reveal must have one input");
184    let source = reveal.input[0].previous_output;
185    ensure!(
186        source.txid == commit.compute_txid(),
187        "reveal does not spend the supplied commit"
188    );
189    let prevout = commit
190        .output
191        .get(usize::try_from(source.vout)?)
192        .context("commit outpoint absent")?;
193    verify_prevout_proof(reveal, prevout)
194}
195
196pub fn verify_prevout(reveal: &Transaction, prevout: &TxOut) -> Result<ParsedEnvelope, Error> {
197    let parsed = verify_prevout_proof(reveal, prevout)?;
198    validate_record(&parsed.record)?;
199    Ok(parsed)
200}
201
202fn verify_prevout_proof(reveal: &Transaction, prevout: &TxOut) -> Result<ParsedEnvelope, Error> {
203    let parsed = extract_reveal_envelope(reveal)?;
204    ensure!(
205        reveal.output[0].value <= prevout.value,
206        "reveal spends more than prevout"
207    );
208    ensure!(
209        prevout.script_pubkey.is_p2tr(),
210        "commit output must be P2TR"
211    );
212    let secp = Secp256k1::verification_only();
213    let output_key = XOnlyPublicKey::from_slice(&prevout.script_pubkey.as_bytes()[2..34])?;
214    ensure!(
215        parsed
216            .control
217            .verify_taproot_commitment(&secp, output_key, &parsed.script),
218        "invalid Taproot commitment"
219    );
220    let signature = bitcoin::secp256k1::schnorr::Signature::from_slice(
221        reveal.input[0]
222            .witness
223            .iter()
224            .next()
225            .context("signature absent")?,
226    )?;
227    let hash = SighashCache::new(reveal).taproot_script_spend_signature_hash(
228        0,
229        &Prevouts::All(std::slice::from_ref(prevout)),
230        TapLeafHash::from_script(&parsed.script, LeafVersion::TapScript),
231        TapSighashType::Default,
232    )?;
233    secp.verify_schnorr(
234        &signature,
235        &Message::from_digest(hash.to_byte_array()),
236        &parsed.author,
237    )
238    .context("invalid author signature")?;
239    Ok(parsed)
240}
241pub fn extract_reveal(reveal: &Transaction) -> Result<ParsedEnvelope, Error> {
242    let parsed = extract_reveal_envelope(reveal)?;
243    validate_record(&parsed.record)?;
244    Ok(parsed)
245}
246
247fn extract_reveal_envelope(reveal: &Transaction) -> Result<ParsedEnvelope, Error> {
248    validate_reveal_shape(reveal.version.0, reveal.input.len(), reveal.output.len())?;
249    validate_reveal_scripts(&reveal.input[0].script_sig, &reveal.output[0].script_pubkey)?;
250    extract_envelope(&reveal.input[0].witness)
251}
252
253pub fn validate_reveal_shape(version: i32, inputs: usize, outputs: usize) -> Result<(), Error> {
254    ensure!(
255        version == Version::TWO.0 && inputs == 1 && outputs == 1,
256        "invalid reveal transaction shape"
257    );
258    Ok(())
259}
260
261pub fn validate_reveal_scripts(script_sig: &Script, return_script: &Script) -> Result<(), Error> {
262    ensure!(script_sig.is_empty(), "reveal scriptSig must be empty");
263    ensure!(
264        return_script.is_p2wpkh() || return_script.is_p2tr(),
265        "invalid reveal return output"
266    );
267    Ok(())
268}