1use crate::bitcoin_rpc::supported_network;
2use crate::config::{BITCOIN_MAX_FEE_RATE, BITCOIN_MAX_FEE_SATS, BITCOIN_RETURN_SATS, Limits};
3use crate::container;
4use crate::error::{Context, Error, ensure};
5use crate::transaction::decode;
6use bitcoin::{
7 Address, OutPoint, ScriptBuf, Sequence, Transaction, TxOut, Witness, absolute,
8 sighash::EcdsaSighashType, transaction::Version,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::{collections::BTreeSet, str::FromStr};
13use urma_core::envelope;
14use urma_core::format::Urma;
15use urma_wallet::funding::Funding;
16
17#[derive(Clone, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Plan {
20 pub version: u8,
21 pub network: String,
22 pub object_id: String,
23 pub start_height: u64,
24 pub input_bytes: usize,
25 pub fee_sats: u64,
26 pub commit_fee_sats: u64,
27 pub reveal_fee_sats: u64,
28 pub fee_rate_sat_vb: u64,
29 pub funding: Funding,
30 pub commit: String,
31 pub reveals: Vec<String>,
32 pub mining_address: String,
33}
34
35pub(crate) fn decode_transaction(raw: &str) -> Result<Transaction, Error> {
36 decode(raw, 8_000_000)
37}
38
39fn validate_shape(tx: &Transaction) -> Result<(), Error> {
40 ensure!(
41 tx.version == Version::TWO && tx.lock_time == absolute::LockTime::ZERO,
42 "unexpected transaction version or locktime"
43 );
44 ensure!(
45 tx.input.len() == 1
46 && tx.input[0].script_sig.is_empty()
47 && tx.input[0].sequence == Sequence::ENABLE_RBF_NO_LOCKTIME,
48 "unexpected transaction input structure"
49 );
50 ensure!(
51 tx.weight().to_wu() <= 400_000,
52 "transaction exceeds standard weight"
53 );
54 Ok(())
55}
56
57pub fn validate_plan(plan: &Plan) -> Result<Value, Error> {
58 let network = supported_network(&plan.network)?;
59 let payout = Address::from_str(&plan.mining_address)?.require_network(network)?;
60 ensure!(
61 payout.script_pubkey().is_p2tr(),
62 "journal payout must be Taproot"
63 );
64 validate_transparent_plan(plan, &payout.script_pubkey(), true)
65}
66
67pub(crate) fn validate_transparent_plan(
68 plan: &Plan,
69 payout: &ScriptBuf,
70 require_signed_commit: bool,
71) -> Result<Value, Error> {
72 validate_metadata(plan)?;
73 let (outpoint, previous) = plan.funding.prevout()?;
74 let commit = decode_transaction(&plan.commit)?;
75 let commit_fee = validate_commit(
76 plan,
77 payout,
78 &commit,
79 outpoint,
80 &previous,
81 require_signed_commit,
82 )?;
83 let change = commit.output.last().context("commit change missing")?;
84 let commit_id = commit.compute_txid();
85 let (reveal_fee, reveal_ids, reveal_sizes) = validate_reveals(plan, payout, &commit)?;
86 ensure!(
87 reveal_fee == plan.reveal_fee_sats
88 && commit_fee.checked_add(reveal_fee) == Some(plan.fee_sats),
89 "journal total fees differ from signed transactions"
90 );
91 Ok(
92 json!({"status": "valid", "network": plan.network, "object_id": plan.object_id,
93 "input_bytes": plan.input_bytes, "start_height": plan.start_height, "total_chunks": plan.reveals.len(),
94 "fee_rate_sat_vb": plan.fee_rate_sat_vb, "fee_sats": plan.fee_sats,
95 "commit_fee_sats": commit_fee, "reveal_fee_sats": reveal_fee,
96 "commit_txid": commit_id.to_string(), "reveal_txids": reveal_ids,
97 "commit_vsize": commit.vsize(), "reveal_vsizes": reveal_sizes,
98 "funding_txid": outpoint.txid.to_string(), "funding_vout": outpoint.vout,
99 "funding_sats": previous.value.to_sat(), "change_sats": change.value.to_sat(),
100 "payout_address": plan.mining_address, "chain_status_checked": false}),
101 )
102}
103
104fn validate_metadata(plan: &Plan) -> Result<(), Error> {
105 if plan.input_bytes > Limits::INPUT_BYTES {
106 return Err(Error::Capacity("client input capacity exceeded".into()));
107 }
108
109 ensure!(
110 plan.version == Urma::VERSION,
111 "unsupported publication journal"
112 );
113 ensure!(
114 (1..=Limits::RECORDS).contains(&plan.reveals.len()),
115 "invalid reveal count"
116 );
117 ensure!(
118 (1..=Limits::INPUT_BYTES).contains(&plan.input_bytes),
119 "invalid input length"
120 );
121 ensure!(
122 plan.input_bytes.div_ceil(Urma::CHUNK_BYTES) == plan.reveals.len(),
123 "journal input length disagrees with reveal count"
124 );
125 ensure!(
126 (1..=BITCOIN_MAX_FEE_RATE).contains(&plan.fee_rate_sat_vb),
127 "invalid journal fee rate"
128 );
129 ensure!(
130 plan.fee_sats <= BITCOIN_MAX_FEE_SATS,
131 "journal exceeds client fee cap"
132 );
133 ensure!(
134 hex::decode(&plan.object_id)?.len() == 32,
135 "invalid journal object ID"
136 );
137 Ok(())
138}
139fn validate_commit(
140 plan: &Plan,
141 payout: &ScriptBuf,
142 commit: &Transaction,
143 outpoint: OutPoint,
144 previous: &TxOut,
145 require_signed_commit: bool,
146) -> Result<u64, Error> {
147 validate_shape(commit)?;
148 ensure!(
149 commit.input[0].previous_output == outpoint,
150 "commit does not spend supplied funding"
151 );
152 ensure!(
153 commit.output.len() == plan.reveals.len() + 1,
154 "commit has unexpected outputs"
155 );
156 let change = commit.output.last().context("commit change missing")?;
157 ensure!(
158 change.script_pubkey == *payout && change.value.to_sat() >= BITCOIN_RETURN_SATS,
159 "invalid commit change destination or value"
160 );
161 let output_sats = commit
162 .output
163 .iter()
164 .try_fold(0u64, |sum, output| sum.checked_add(output.value.to_sat()))
165 .context("commit output sum overflow")?;
166 let commit_fee = previous
167 .value
168 .to_sat()
169 .checked_sub(output_sats)
170 .context("commit spends more than funding")?;
171 ensure!(
172 commit_fee == plan.commit_fee_sats,
173 "journal commit fee differs from signed transaction"
174 );
175 let mut worst_case = commit.clone();
176 worst_case.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
177 ensure!(
178 commit_fee == u64::try_from(worst_case.vsize())? * plan.fee_rate_sat_vb
179 && commit_fee >= u64::try_from(commit.vsize())? * plan.fee_rate_sat_vb,
180 "commit fee is inconsistent with selected rate"
181 );
182
183 verify_funding_signature(commit, previous, require_signed_commit)?;
184 Ok(commit_fee)
185}
186fn verify_funding_signature(
187 commit: &Transaction,
188 previous: &TxOut,
189 require_signed_commit: bool,
190) -> Result<(), Error> {
191 if require_signed_commit {
192 let funding_witness: Vec<_> = commit.input[0].witness.iter().collect();
193 ensure!(
194 funding_witness.len() == 2,
195 "invalid P2WPKH signature witness"
196 );
197 let signature = bitcoin::ecdsa::Signature::from_slice(funding_witness[0])?;
198 ensure!(
199 signature.sighash_type == EcdsaSighashType::All,
200 "commit must sign all outputs"
201 );
202 let public = bitcoin::PublicKey::from_slice(funding_witness[1])?;
203 let compressed = bitcoin::CompressedPublicKey::try_from(public)?;
204 urma_wallet::signing::verify_p2wpkh_signature(commit, 0, previous, &signature, &compressed)
205 .map_err(|cause| match cause {
206 urma_wallet::signing::FundingSignatureError::SighashType => {
207 Error::Invalid("commit must sign all outputs".into())
208 }
209 urma_wallet::signing::FundingSignatureError::PublicKeyMismatch => {
210 Error::Invalid("funding key does not match previous output".into())
211 }
212 urma_wallet::signing::FundingSignatureError::Sighash(cause) => cause.into(),
213 urma_wallet::signing::FundingSignatureError::Signature(cause) => Error::Context {
214 message: "invalid commit signature".into(),
215 cause: Box::new(cause.into()),
216 },
217 })?;
218 } else {
219 ensure!(
220 commit.input[0].witness.is_empty(),
221 "draft commit must be unsigned"
222 );
223 }
224
225 Ok(())
226}
227fn validate_reveals(
228 plan: &Plan,
229 payout: &ScriptBuf,
230 commit: &Transaction,
231) -> Result<(u64, Vec<String>, Vec<usize>), Error> {
232 let mut seen = BTreeSet::new();
233 let mut seen_chunks = BTreeSet::new();
234 let mut discovery_tag = None;
235 let mut reveal_fee = 0u64;
236 let mut reveal_ids = Vec::new();
237 let mut reveal_sizes = Vec::new();
238 for raw in &plan.reveals {
239 let reveal = decode_transaction(raw)?;
240 validate_shape(&reveal)?;
241 let source = reveal.input[0].previous_output;
242 ensure!(
243 source.txid == commit.compute_txid()
244 && (usize::try_from(source.vout)?) < plan.reveals.len(),
245 "reveal does not spend a commit media output"
246 );
247 ensure!(
248 seen.insert(source.vout),
249 "duplicate reveal spends the same commit output"
250 );
251 ensure!(
252 reveal.output.len() == 1
253 && reveal.output[0].value.to_sat() == BITCOIN_RETURN_SATS
254 && reveal.output[0].script_pubkey == *payout,
255 "invalid reveal payout"
256 );
257 let prevout = &commit.output[usize::try_from(source.vout)?];
258 ensure!(
259 prevout.script_pubkey.is_p2tr(),
260 "commit media output is not Taproot"
261 );
262 let fee = prevout
263 .value
264 .to_sat()
265 .checked_sub(BITCOIN_RETURN_SATS)
266 .context("reveal spends more than commit output")?;
267 ensure!(
268 fee == u64::try_from(reveal.vsize())? * plan.fee_rate_sat_vb,
269 "reveal fee differs from selected rate"
270 );
271 reveal_fee = reveal_fee
272 .checked_add(fee)
273 .context("reveal fee sum overflow")?;
274 let record = envelope::verify_reveal(&reveal, commit)?.record;
275 let header = container::inspect_header(&record)?;
276 ensure!(
277 header.id.as_slice() == hex::decode(&plan.object_id)?
278 && usize::try_from(header.count)? == plan.reveals.len(),
279 "reveal metadata differs from journal"
280 );
281 ensure!(seen_chunks.insert(header.index), "duplicate record index");
282 let tag = header.discovery_tag;
283 for previous_tag in discovery_tag.iter() {
284 ensure!(*previous_tag == tag, "inconsistent discovery tags");
285 }
286 discovery_tag = Some(tag);
287 reveal_ids.push(reveal.compute_txid().to_string());
288 reveal_sizes.push(reveal.vsize());
289 }
290 Ok((reveal_fee, reveal_ids, reveal_sizes))
291}