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}