Skip to main content

urma_runtime/
journal.rs

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}