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